Controllers, Services, and Repositories
Every backend I write follows the same pattern: controllers handle HTTP, services contain business logic, repositories talk to databases. It's not exciting. It's not novel. But it works, and I've stopped fighting it.
This post is about why that pattern exists, how it relates to hexagonal architecture, and the dependency injection decisions that make it practical. I'll also explain why I use classes instead of functions, even though the purists would disapprove.
The Stack
Here's the basic structure:
// Controller - handles HTTP concerns
class UserController {
constructor(private readonly userService: IUserService) {}
getUser = async (req: Request, res: Response) => {
const result = await this.userService.getById(req.params.id);
if (result.isErr()) {
return sendNotFound(res, result.error.message);
}
return sendOk(res, result.value);
};
}
// Service - business logic
class UserService implements IUserService {
constructor(private readonly userRepository: UserRepository) {}
getById = async (id: string) => {
return this.userRepository.get(id);
};
}
// Repository - data access
class UserRepository extends CommonKnexRepository<UserDbRecord, User> {
protected get tableName() { return "users"; }
// ...
}The request flows left to right: Controller → Service → Repository. Each layer has a single responsibility.
Why Bother?
The obvious answer is separation of concerns. Controllers don't know about databases. Services don't know about HTTP status codes. Repositories don't know about business rules.
But the real value is testability and changeability. When your service depends on an interface rather than a concrete repository, you can swap implementations. Postgres today, DynamoDB tomorrow. Real database in production, in-memory mock in tests. The service doesn't care.
This is the core idea behind hexagonal architecture, sometimes called ports and adapters. Chris Richardson explains it well in his geometry of microservices:
The hexagonal architecture puts the business logic at the heart of the service. Inbound adapters that handle requests from the service's clients and outbound adapters which make requests depend on the business logic.
Controllers are inbound adapters. Repositories are outbound adapters. Services are the hexagon's core. The direction of dependency always points inward.
Repositories and Adapters
If you squint, repositories and adapters are the same thing. A repository abstracts away data storage. An adapter abstracts away any external system.
In my codebase, I have repositories for databases and "providers" for AWS services:
// Repository - abstracts the database
const userRepository = new UserRepository(db);
// Provider - abstracts AWS
const blobStorageProvider: IBlobStorageProvider = new S3BlobStorageProvider(
s3Client
);Both are outbound adapters. Both hide implementation details from the service layer. The naming convention is just a signal: repositories store entities, providers integrate with external services. The pattern is identical.
Dependency Injection
Here's where it gets interesting. Every service needs its dependencies. The question is: how do they get them?
The service locator pattern is the obvious approach, and also the wrong one:
// Don't do this
import { db } from "./db";
// and this isn't better
const db = createDb("...");
class UserService {
getById = async (id: string) => {
return db.query("SELECT * FROM users WHERE id = ?", [id]);
};
}Global state. Impossible to test. Can't swap implementations. It's the anti-pattern that every DI framework exists to prevent.
The Evolu documentation has a nice explanation of why this matters:
Some function arguments are local—say, the return value of one function passed to another—and often used just once. Others, like a database instance, are global and needed across many functions. Traditionally, when something must be shared across functions, we might make it global using a 'service locator,' a well-known antipattern.
Classes vs Functions
There are two main approaches to fixing this. Both pass dependencies explicitly rather than reaching for global state.
The functional approach uses closures:
const createUserService = (deps: { userRepository: UserRepository }) => ({
getById: async (id: string) => deps.userRepository.get(id),
});
// Usage
const userService = createUserService({ userRepository });This is the pattern Evolu advocates. Dependencies are captured in a closure. No class, no this, no mutation. You can even compose services by merging deps objects:
const createOrderService = (deps: {
userRepository: UserRepository;
orderRepository: OrderRepository;
}) => ({
createOrder: async (userId: string, items: Item[]) => {
const user = await deps.userRepository.get(userId);
return deps.orderRepository.create({ user, items });
},
});The class-based approach uses constructor injection:
class UserService {
constructor(private readonly userRepository: UserRepository) {}
getById = async (id: string) => {
return this.userRepository.get(id);
};
}
// Usage
const userService = new UserService(userRepository);Both approaches achieve the same goal: dependencies are explicit, testing is trivial, and swapping implementations happens at the composition root.
Why I Use Classes
I use classes. Here's why.
In practice, dependencies are part of a global context object that gets assembled once at application startup. You're not passing different repositories to the same service at different times. You're wiring everything together in one place and leaving it alone.
// This happens once, in index.ts
const userRepository = new UserRepository(db);
const componentRepository = new ComponentRepository(db);
const projectRepository = new ProjectRepository(db);
const userService = new UserService(userRepository);
const componentService = new ComponentService(componentRepository);
const projectService = new ProjectService(projectRepository, projectMemberRepository);
const userController = new UserController(userService, eventService);Classes make this ergonomic. The constructor signature documents what the service needs. TypeScript ensures you provide it. IDE autocomplete works. It's boring and it works.
The functional approach has real advantages: it's more explicit about immutability, it composes better, and it avoids the footguns that this binding introduces. If you're building something where those properties matter, or if your team prefers functional style, use the Evolu pattern. It's a perfectly valid choice.
The Danger of Late Binding
Classes do afford one thing functions don't: adding dependencies after creation.
class UserService {
private notificationService?: NotificationService;
setNotificationService(service: NotificationService) {
this.notificationService = service;
}
createUser = async (data: CreateUserRequest) => {
const user = await this.userRepository.create(data);
this.notificationService?.sendWelcomeEmail(user);
return user;
};
}This is seductive. It solves circular dependency problems. It lets you wire things up in whatever order you want.
It's also a trap.
When dependencies can be set at any time, you lose the guarantee that the object is fully initialised before use. You introduce temporal coupling: methods only work if someone remembered to call the setter first. The composition root becomes a minefield of ordering constraints.
Worse, it creates inter-service dependencies that aren't visible in the constructor. The UserService depends on NotificationService, but you wouldn't know it from looking at the constructor. The dependency graph becomes implicit.
The fix is simple: if a service needs another service, pass it in the constructor. If that creates a circular dependency, you have a design problem. Either extract a third service that both depend on, or use an event-based approach.
Left-to-Right Flow
Good architecture has a direction. Dependencies flow inward (toward the domain). Requests flow left-to-right (controller → service → repository).
When you start adding dependencies after creation, or letting services call each other directly, you lose that direction. Service A calls Service B which calls Service A. The call graph becomes a web instead of a tree. You've built a ball of mud.
I've seen this happen. A notification service that calls a user service that calls a notification service. An event handler that triggers another event that triggers the original handler. Debugging becomes archaeology.
The rule is simple: if you can't draw a clean dependency graph, something is wrong.
Events and Distributed Monoliths
There's one case where bidirectional dependencies are intentional: event systems.
// Service emits event
const user = await this.userRepository.create(data);
this.eventService.emit("user.created", { userId: user.id });
// Somewhere else, another service listens
eventService.on("user.created", async (event) => {
await notificationService.sendWelcomeEmail(event.userId);
});Events create implicit dependencies. The user service doesn't know who's listening. The notification service doesn't know who's emitting. The coupling is there, but it's invisible.
This is the foundation of microservices architecture. Services communicate through events. They're loosely coupled at the code level, tightly coupled at the protocol level.
For true microservices with separate deployments, this is the right approach. But I've seen teams adopt event-driven architecture inside a monolith and create what's sometimes called a distributed monolith. All the complexity of microservices, none of the benefits.
Here's the thing: for smaller businesses, this style of inter-service communication is fine. You're not deploying services independently. You're not scaling them independently. You're just organising code. The events are a coordination mechanism, not a deployment boundary.
The problems come when you pretend you're building microservices but you're really building a monolith with extra steps. If all your services are deployed together, share a database, and can't function independently, you don't have microservices. You have a complex monolith with RPC disguised as events.
Know which one you're building. Both are valid. Pretending one is the other is where projects go to die.
Conclusion
Controller-service-repository is not the only pattern. It's not even the most sophisticated. But it's predictable, testable, and hard to mess up.
The key ideas:
- Dependencies flow inward, toward the domain
- Requests flow left-to-right, through the layers
- Constructor injection makes dependencies explicit
- Late binding creates hidden coupling
- Events are fine, but know if you're building a monolith or microservices
If you want functional purity, use the Evolu pattern. If you want pragmatic simplicity, use classes with constructor injection. Either way, keep the dependency graph clean and the flow directional.
The architecture that ships is better than the architecture that's theoretically perfect.