Structuring an app
Two recommendations for an app that grows beyond one file. Both are defaults, not rules: each section says when to do otherwise.
Keep the web framework out of the core
Build the container around the application core: configuration, connections, repositories, domain services. Keep the web bindings (routes, middleware, the server) in a layer of their own that receives the core and resolves what it needs from it:
// core/container.ts: no web framework imports
export const createCore = () =>
new DIContainer()
.addInstance('config', loadConfig())
.extend(addDatabase)
.namespace('Orders', addOrders)
.seal();
export type Core = ReturnType<typeof createCore>;// web/orders.ts: the web layer gets services through a provider
export const ordersRouter = (
core: ServiceProvider<{ 'Orders.orders': OrderService }>,
) => {
const orders = core.get('Orders.orders');
const router = Router();
router.get('/', async (_req, res) => {
res.json(await orders.list());
});
return router;
};
// web/server.ts
export const createHttpApp = (core: Core) => {
const http = express();
http.use(express.json());
http.use('/orders', ordersRouter(core));
return http;
};The entry point connects them, and stops the server before the core releases its connections:
// main.ts
const core = createCore();
const running = await startLifecycle(core);
const server = createHttpApp(core).listen(core.get('config').port);
process.once('SIGTERM', () =>
server.close(() => running.stop().catch((error) => console.error(error))),
);The dependency points one way: the web layer imports the core, and the core never imports the web layer.
What you get
- One core for every entry point. The HTTP server, a queue worker, a cron job and a CLI build the same core and add their own bindings. None of them carries another's framework.
- The framework is replaceable. Upgrading Express or moving to Fastify or Hono changes the web layer only. The core, and its tests, don't change.
- Tests at the right level. Core tests use an isolated fork and send no HTTP requests. Web tests build the web layer on a core fork with fakes.
- The framework works its own way. Plugin encapsulation, route-level typing (Hono's RPC client, Fastify's type providers) and the framework's test helpers are used as their docs describe, without passing through the container.
- A readable container. The services graph shows the domain, not routers and middleware. The container type stays smaller, so it is faster to typecheck.
What it costs
- A seam to maintain. The web layer resolves services at its edge, through a
ServiceProvidertyped with what it needs. That is service location, kept to one layer: below it, everything is injected. - Routes are listed by hand. A feature module can't contribute its routes on its own: the web layer mounts each feature's router. That list is explicit, but it is one more place to touch when you add a feature.
- You order the shutdown. The server isn't a lifecycle hook, so the entry point stops it before
running.stop().
When to put the framework in the container
It works: the framework pages show it. Prefer it when:
- features are plugins that bring their own routes, and adding one must not touch the web layer (an extension point for routes);
- the server is one of several things the lifecycle starts and stops in order (consumers, schedulers), and you want it as a hook like the others;
- the app is small, and one container is simpler than two layers.
Keep the wiring in files of its own
Put each module's registrations in a file that only wires, and the composition root in one more:
src/
orders/
orders-service.ts plain classes: no injecute imports
orders-repository.ts
orders.container.ts addOrders: registrations only
billing/
billing.container.ts
container.ts the composition root: createApp()
main.ts the entry point: start, signals// orders/orders.container.ts
import type { ServiceRegistry } from 'injecute';
import { OrdersRepository } from './orders-repository.ts';
import { OrdersService } from './orders-service.ts';
export const addOrders = (c: ServiceRegistry<{ db: Database }>) =>
c
.addSingleton('repo', OrdersRepository, ['db'])
.addSingleton('orders', OrdersService, ['repo']);// container.ts
export const createApp = () =>
new DIContainer()
.addInstance('config', loadConfig())
.extend(addDatabase)
.namespace('Orders', addOrders)
.namespace('Billing', addBilling)
.seal();A feature with services that belong to a scope (a session, a request, a transaction) exports a second module for the scope; see Scope modules.
What belongs in a wiring file: imports of classes and factories, registrations, and small adapters ((config) => config.port). What doesn't: business logic, classes, side effects on import, and built containers. Export a module function or createApp(), never a container created at the top of the file.
What you get
- Plain classes. Services don't import injecute or the container. They are tested with
new, reused without the container, and unchanged if you replace it. - The wiring in one place. A module's dependencies are readable in one file, and a change to them is a change to that file in review.
- No import cycles. Wiring files import services; services never import wiring.
- Safe to import. A wiring file has no side effects, so tests, scripts and tools (
buildServicesGraph()) load it freely, and eachcreateApp()call is a new container. A test never shares the app's instances by accident. - Easy to adopt and to remove. Introducing injecute adds wiring files; removing it deletes them.
What it costs
- More files. A small feature has a service file and a wiring file of a few lines.
- The dependency list is away from the constructor. You read two files to see a class's dependencies. The compiler checks that the list matches the constructor, so a mismatch is a compile error, not a bug.
- A naming convention to agree on, such as
*.container.tsormodule.tsper feature.
When one file is enough
A script or a small service with a handful of registrations: keep them in one container.ts. Split when a feature's wiring grows, or when two features change it in the same week.