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.
Calls across applications
Within one process, inject the service. Across Titan applications, expose a versioned Netron service and call its typed interface through the Netron client. Give the service method its authorization policy once. The service evaluates the same policy for browser, CLI and service callers. Grant access according to each caller’s authenticated identity.
Use a Netron transport suited to the connection. For example, a browser can use HTTP for a request and WebSocket for a subscription. Netron owns the handshake, method invocation and event framing on those connections. When integrating a provider with its own wire protocol, keep that client in the provider adapter and expose the application operation through Netron. Netron transports
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({ input: CreateOrderSchema })
async create(input: CreateOrder): Promise<Order> {
await this.validateOwnership(input.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.