Structure business logic with DI
You will create a service layer with dependency injection, organize it into a module, and inject it into API endpoints.
Steps
1) Define a service
Services are plain classes. Dependencies come through the constructor:
// src/services/user.service.ts
export class UserService {
constructor(private db: DatabaseService) {}
async getUser(id: string) {
return this.db.query(`SELECT * FROM users WHERE id = $1`, [id]);
}
async listUsers() {
return this.db.query(`SELECT * FROM users`);
}
}No decorators, no framework imports — just a class.
2) Register providers
In src/main.ts, register services with explicit dependencies:
import { application, http, api } from '@putnami/application';
export const app = () =>
application()
.use(http())
.use(api())
.provide(DatabaseService)
.provide(UserService, { deps: [DatabaseService] });The deps array tells the DI container what to pass to the constructor, in order.
3) Inject into endpoints
Use .inject() on endpoint(), loader(), or action() builders. The injected dependencies become the first argument of the handler:
// src/api/users/get.ts
import { endpoint } from '@putnami/application';
import { UserService } from '../../services/user.service';
export default endpoint()
.inject({ users: UserService })
.handle(async ({ users }) => {
return { users: await users.listUsers() };
});// src/api/users/[id]/get.ts
import { endpoint, Uuid } from '@putnami/application';
import { UserService } from '../../../services/user.service';
export default endpoint()
.params({ id: Uuid })
.inject({ users: UserService })
.handle(async ({ users }, ctx) => {
const user = await users.getUser(ctx.params.id);
if (!user) throw new HttpException(404, 'User not found');
return { user };
});4) Group into modules
When your service layer grows, group related providers into modules with explicit contracts:
// src/modules/auth.module.ts
import { module } from '@putnami/application';
export const authModule = module('auth')
.require(DatabaseService) // Must exist in parent
.provide(AuthService, { deps: [DatabaseService] }) // Public — usable by endpoints
.provide(TokenValidator, { visibility: 'private' }); // Private — internal to moduleMount the module in your application:
export const app = () =>
application()
.use(http())
.use(api())
.provide(DatabaseService)
.use(authModule);5) Use scoped providers for per-request state
Mark providers as scoped to get a fresh instance per HTTP request:
export const app = () =>
application()
.provide(RequestContext, { scope: 'scoped' })
.provide(AuditLogger, { deps: [RequestContext] });Scoped providers are automatically resolved per request when injected into endpoints.
6) Test with fork
Fork the DI context and override providers for test isolation:
import { describe, test, expect } from 'bun:test';
import { ContainerContext, provide } from '@putnami/runtime';
describe('UserService', () => {
test('lists users', async () => {
await using ctx = new ContainerContext('test');
ctx.register(provide(DatabaseService, () => ({
query: async () => [{ id: '1', name: 'Alice' }],
})));
ctx.register(provide(UserService, { deps: [DatabaseService] }));
await ctx.start();
const users = ctx.get(UserService);
const result = await users.listUsers();
expect(result).toHaveLength(1);
});
});Next steps
- See the Dependency Injection reference for scopes, tags, lazy providers, and tracing
- Add persistence for database-backed services
- Add authentication to protect injected endpoints
Companion sample:
typescript/samples/05-dependency-injection— runputnami serve @example/05-dependency-injectionto see DI in action.
You now have a service layer with dependency injection, organized into modules, injected into type-safe endpoints, and testable in isolation.