ENGINEERING NOTES
REST API Architecture: Controller → Service → Repository
Place HTTP, business rules and SQL behind boundaries that support transactions and focused tests.
What problem does this solve?
A controller that mixes parsing, permissions, payment calls and SQL becomes hard to change safely. Layering helps isolate reasons for change, provided it does not turn every line into a pass-through abstraction.
What is it?
Controllers translate HTTP requests and responses. Services coordinate use cases and business rules. Repositories express persistence operations. REST resource design remains a separate concern: meaningful methods, status codes, validation and authorization are still required.
Mental model
The controller is an adapter, the service is the decision maker and the repository is the database translator. A service should not need Express Request or Response to execute a use case.
How it works internally
The composition root constructs dependencies, then injects them into services and controllers. Repository interfaces should describe useful domain operations. A transaction spanning multiple writes must share one database client; hiding transaction ownership independently in each repository breaks atomicity.
Production example
An order-creation use case validates authorization, reserves inventory and records an outbox event atomically. HTTP parsing belongs at the boundary, while SQL constraints remain a final defense against concurrent requests.
Node.js / TypeScript implementation
A small service with an injected atomic repository operation. A real adapter should implement reserveAndCreate with a transaction and an inventory constraint; this example demonstrates the dependency boundary.
type Order = { id: string };
interface Orders {
reserveAndCreate(productId: string, quantity: number): Promise<Order>;
}
function createOrderService(orders: Orders) {
return async (productId: string, quantity: number) => {
if (!Number.isInteger(quantity) || quantity < 1)
throw new Error("Quantity must be a positive integer");
return orders.reserveAndCreate(productId, quantity);
};
}
const createOrder = createOrderService({
reserveAndCreate: async () => ({ id: "example-order" })
});
createOrder("product-1", 2).then(console.log);Common mistakes
A generic repository for every table can obscure important queries and locking. Returning HTTP responses from a service couples it to transport. Performing a check and write separately permits races even when the code appears cleanly layered.
Performance / reliability considerations
Inspect query count and transaction duration across the whole use case. Avoid remote calls inside long database transactions. Unit-test decisions with injected adapters, then integration-test actual constraints and rollback behavior; mocks cannot prove SQL correctness.
Interview questions
Where does transaction ownership belong? Why keep Express types out of business rules? When is a separate repository unnecessary? Which rules require a database constraint as well as application validation?
Quick Revision
Controller: HTTP adaptation. Service: use-case decisions. Repository: persistence. Inject dependencies at composition time. Share transaction context across related writes. Use constraints for concurrent correctness. Prefer concrete boundaries over layers added only for naming symmetry. Test both decisions and actual database behavior.