The Repository Pattern in NestJS: a collection that happens to live in a database
The canonical starting point for a NestJS module backed by TypeORM is the one the framework itself documents: the module declares TypeOrmModule.forFeature([Order]) and the service receives the repository by injection.
@Injectable()
export class OrderService {
constructor(
@InjectRepository(Order)
private readonly repo: Repository<Order>,
) {}
}
From there the service has find, findOne, save and delete at hand, and can write its first business rule with no further scaffolding. It is the path of least friction and the best documented one, which is why it is the one most codebases are built on.
The code that ends up living inside that service takes this shape:
async confirm(orderId: string): Promise<Order> {
const order = await this.repo.findOne({
where: { id: orderId, status: 'pending' },
relations: { lines: true },
});
if (!order) throw new NotFoundException('Order not found');
if (order.lines.length === 0) {
throw new BadRequestException('Cannot confirm an order without lines');
}
order.status = 'confirmed';
return this.repo.save(order);
}
The method is correct: it does what it promises, it reads well, and it can carry years of production without an incident. What is worth analysing is not its behaviour but its coupling surface.
There is a genuine business rule in there - an order cannot be confirmed with no lines - and it pays to measure how much knowledge of the persistence engine ended up embedded in it. The inventory is longer than it looks:
- The rule depends on how the row was loaded. The invariant is evaluated over
order.lines, and that collection only exists if the query asked for the relation explicitly. Ifrelations: { lines: true }disappears in a refactor,order.linesarrives empty, the check fires when it should not, and the invariant is inverted without anything failing: no compile error, no exception, no trace. The rule's correctness is a property of the query, not of the rule. - The business condition is expressed in table vocabulary. "Pending" is not a domain concept in this code; it is the string
'pending'compared against a column inside awhereobject. - Control flow is dictated by the ORM's API. The first
ifexists becausefindOnereturnsnull; that is a TypeORM decision, not a business one. - Write semantics are implicit.
savedecides on its own whether the operation is an INSERT or an UPDATE based on the state of the primary key. The service inherits that ambiguity. - The business class is the schema definition.
Order- where the total calculation and the state transitions will eventually live - is the same class carrying the@Columndecorators that describe the table.
The first four are coupling nuisances: awkward, but local and reversible. The fifth is of another kind. It is not a consequence of how this method was written, but of a structural decision - that the business model and the persistence model be the same object - which the project adopted without deliberating it, by following the documented path.
That coupling has no observable cost as long as the module stays on single-entity operations over a single table. It becomes measurable when three conditions show
Comments
No comments yet. Start the discussion.