Summary
The development team encountered a leaky abstraction when attempting to scale a Repository pattern implementation. While the initial goal was to decouple the Service layer from the database, the introduction of complex relational requirements (1:1, 1:M, and N:M associations) forced the Service layer to manage database-specific concerns like transactions and Sequelize-specific methods. This resulted in a violation of the Single Responsibility Principle and rendered the Service layer dependent on the underlying ORM.
Root Cause
The core issue is the mismatch between a simplistic Repository interface and the requirements of atomic, multi-entity business transactions.
- Granularity Mismatch: The Repository was designed for single-table CRUD, but the business requirement is a “Complex Aggregate” creation.
- Transaction Leakage: To ensure data integrity across multiple tables, a database transaction must be shared. If the Service layer manages the transaction, it becomes “aware” of the database implementation.
- Association Complexity: Using Sequelize’s
{ include }feature requires the Repository to accept highly coupled, ORM-specific nested objects, which defeats the purpose of a clean Data Transfer Object (DTO). - Lack of a Unit of Work: The architecture lacked a mechanism to coordinate multiple repository operations under a single atomic boundary without exposing the ORM to the caller.
Why This Happens in Real Systems
In evolving production environments, systems often start with “Anemic Domain Models” where repositories are mere wrappers around SQL queries. As business logic grows:
- Complexity Creep: Simple entities evolve into Aggregates (in Domain-Driven Design terms) that require coordinated state changes across multiple tables.
- ORM Magic: ORMs like Sequelize provide powerful features (like nested includes) that are easy to use but create tight coupling if not wrapped behind a rigorous abstraction.
- The Transaction Dilemma: Engineers often struggle to decide whether the Service (the orchestrator) or the Repository (the data gatekeeper) should own the transaction lifecycle.
Real-World Impact
- Reduced Testability: You cannot unit test the Service layer without mocking the entire Sequelize transaction object.
- Maintenance Fragility: Changing the ORM (e.g., moving from Sequelize to TypeORM or Prisma) requires a complete rewrite of the Service layer.
- Data Inconsistency: If the Service layer coordinates multiple repository calls without a shared transaction, a failure in the third call (e.g.,
photoRepo.bulkCreate) leaves the first two calls (e.g.,estateRepo.create) committed, leading to orphan records and corrupted state.
Example or Code (if necessary and relevant)
interface CreateEstateDTO {
name: string;
address: { street: string; city: string };
amenityIds: number[];
photoUrls: string[];
}
class EstateRepository {
async createWithDetails(
data: CreateEstateDTO,
transaction: Transaction
): Promise {
return await sequelize.transaction({ transaction: true }, async (t) => {
const estate = await Estate.create({
name: data.name,
// 1:1 Association
Address: { street: data.address.street, city: data.address.city }
}, { transaction: t, include: [Address] });
// N:M Association
await estate.setAmenities(data.amenityIds, { transaction: t });
// 1:M Association
await Photo.bulkCreate(
data.photoUrls.map(url => ({ url, estateId: estate.id })),
{ transaction: t }
);
return estate;
});
}
}
How Senior Engineers Fix It
Senior engineers resolve this by implementing a Unit of Work pattern or by strictly defining Aggregate Roots.
- Aggregate Roots: Recognize that an
Estateis an Aggregate Root. TheEstateRepositoryshould be the only entry point for creating an Estate and its dependencies. The Service layer should only pass a clean DTO to the Repository. - Unit of Work (UoW): Introduce a UoW class that manages the transaction lifecycle. The Service layer requests a transaction from the UoW and passes it to the repositories. This keeps the Service agnostic of the implementation (Sequelize) while remaining responsible for the scope of the transaction.
- Data Mapping: Use a dedicated Mapper to convert the clean DTO into the complex, nested object structure that the ORM requires inside the Repository implementation.
- Dependency Inversion: Ensure the Service layer depends on an interface (e.g.,
IEstateRepository), and the Sequelize-specific logic is hidden behind the concrete implementation.
Why Juniors Miss It
- Focus on “How” instead of “Where”: Juniors focus on making the code work (using
includeorsetAmenities) rather than where that logic belongs in the architectural layers. - The “Convenience Trap”: They often use ORM features directly in the Service layer because it is faster and requires less boilerplate code.
- Misunderstanding Abstraction: They view the Repository as a collection of “Table Helpers” rather than a provider of “Domain Aggregates.”
- Ignoring Atomicity: They often assume that sequential repository calls are “safe enough,” failing to realize that without a coordinated transaction, a system failure results in partial, invalid data.