Structuring Services
A service is the unit of behaviour. A module is the unit of composition. Get the sizes right and the codebase scales; get them wrong and it doesn't.
Size a service by its bounded context
A service should map to one bounded context — one cohesive area of business meaning. Not one database table, not one HTTP endpoint, not one screen. A bounded context.
| Bad sizing | Better |
|---|---|
UsersTableService (one table) | UsersService (the user concept) |
LoginEndpointService (one route) | AuthService (auth in general) |
OrderListPageService (one screen) | OrdersService (orders in general) |
A service that is too small drowns the codebase in coordination boilerplate. A service that is too large grows uncomfortable to test and refactor.
Rule of thumb: 5–15 @Public methods is healthy. Past 20, start
splitting.
Group services into modules by domain, not by layer
Wrong — by layer:
@Module({ providers: [UsersController, OrdersController, BillingController] })
class ControllersModule {}
@Module({ providers: [UsersRepository, OrdersRepository, BillingRepository] })
class RepositoriesModule {}
The "controllers module" has nothing in common — it bundles unrelated features together because they share a layer name.
Right — by domain:
@Module({ providers: [UsersService, UsersRepository] })
class UsersModule {}
@Module({ providers: [OrdersService, OrdersRepository] })
class OrdersModule {}
Now UsersModule is self-contained. Refactoring it doesn't touch
unrelated code.
Keep @Public methods small
A @Public method is a wire contract. It should:
- Take typed input (validated at the boundary).
- Delegate to private helpers for the work.
- Return a typed output.
Most logic should live in private methods or in injected helpers
(repositories, validators, calculators). The @Public method is a
shell.
@Public()
@Validate(CreateOrderSchema)
async create(input: CreateOrder, @Context() ctx: NetronContext): Promise<Order> {
await this.validateOwnership(ctx.auth!.userId, input.cartId);
const order = await this.repo.create(input);
await this.events.emit('order.created', { orderId: order.id });
return order;
}
private async validateOwnership(userId: string, cartId: string) { /* … */ }
Reading the @Public method gives the reader the wire contract and
the high-level flow. Helpers fill in the details.
Avoid service-to-service calls in the same process when you can
Two services in the same process can call each other directly via DI. This is fine — but it can mask coupling. Two patterns to prefer:
- Shared repository. If both
UsersServiceandOrdersServiceneed to look up users, both injectUsersRepository. They don't needUsersServiceitself. - Domain events. If
OrdersServiceneeds to react to a user change, subscribe touser.updatedrather than callingUsersServicedirectly.
These reduce direct service-to-service dependencies and make splitting into separate processes (later) easier.
Use module-private providers liberally
A provider in providers but not exports is invisible to other
modules. Use this for:
- Internal repositories (
UsersRepository). - Validators (
UsersValidator). - Mappers (
UsersDtoMapper). - Helpers that the public API uses but no one else needs.
Smaller export surfaces mean less coupling.
Avoid the "service sub-classes service" pattern
class BaseService { /* shared methods */ }
@Service('users@1.0.0')
class UsersService extends BaseService { /* … */ }
@Service('orders@1.0.0')
class OrdersService extends BaseService { /* … */ }
Two problems:
- Hidden coupling. A change in
BaseServiceaffects every subclass. - DI confusion. What gets injected into
BaseService? The same thing or different things per subclass?
Prefer composition: extract the shared logic into a helper service and inject it.
@Injectable()
class CommonHelper { /* shared methods */ }
@Service('users@1.0.0')
class UsersService {
constructor(private readonly common: CommonHelper) {}
}
Anti-patterns
- God services. One
AppServicewith 30 methods spanning every domain. Always splittable. - Anemic services. A service that is one method delegating to one repository call. Inline it; the service adds no value.
- Direct database access in
@Publicmethods. Couples wire contract to schema. Use a repository (even a thin one). - State in singleton services. Instance state is shared across
every call. Either move to a
Request-scoped service or store in the database.
→ Next: Error Handling.