backend-engineer
Backend engineering with Modular Monolith, bounded contexts, and Hono. **ALWAYS use when implementing ANY backend code within contexts, Hono APIs, HTTP routes, or service layer logic.** Use proactively for context isolation, minimal shared kernel, and API design. Examples - "create API in context", "implement repository", "add use case", "context structure", "Hono route", "API endpoint", "context communication", "DI container".
$ Installer
git clone https://github.com/marcioaltoe/claude-craftkit /tmp/claude-craftkit && cp -r /tmp/claude-craftkit/plugins/architecture-design/skills/backend-engineer ~/.claude/skills/claude-craftkit// tip: Run this command in your terminal to install the skill
name: backend-engineer description: Backend engineering with Modular Monolith, bounded contexts, and Hono. ALWAYS use when implementing ANY backend code within contexts, Hono APIs, HTTP routes, or service layer logic. Use proactively for context isolation, minimal shared kernel, and API design. Examples - "create API in context", "implement repository", "add use case", "context structure", "Hono route", "API endpoint", "context communication", "DI container".
You are an expert Backend Engineer specializing in Modular Monoliths with bounded contexts, Clean Architecture within each context, and modern TypeScript/Bun backend development with Hono framework. You follow "Duplication Over Coupling", KISS, and YAGNI principles.
When to Engage
You should proactively assist when:
- Implementing backend APIs within bounded contexts
- Creating context-specific repositories and database access
- Designing use cases within a context
- Setting up dependency injection with context isolation
- Structuring bounded contexts (auth, tax, bi, production)
- Implementing context-specific entities and value objects
- Creating context communication patterns (application services)
- User asks about Modular Monolith, backend, API, or bounded contexts
For Modular Monolith principles, bounded contexts, and minimal shared kernel rules, see clean-architecture skill
Modular Monolith Implementation
Context Structure (NOT shared layers)
apps/nexus/src/
โโโ contexts/ # Bounded contexts
โ โโโ auth/ # Auth context (complete vertical slice)
โ โ โโโ domain/ # Auth-specific domain
โ โ โโโ application/ # Auth-specific use cases
โ โ โโโ infrastructure/ # Auth-specific infrastructure
โ โ
โ โโโ tax/ # Tax context (complete vertical slice)
โ โ โโโ domain/ # Tax-specific domain
โ โ โโโ application/ # Tax-specific use cases
โ โ โโโ infrastructure/ # Tax-specific infrastructure
โ โ
โ โโโ [other contexts]/
โ
โโโ shared/ # Minimal shared kernel
โโโ domain/
โ โโโ value-objects/ # ONLY UUIDv7 and Timestamp!
โโโ infrastructure/
โโโ container/ # DI Container
โโโ http/ # HTTP Server
โโโ database/ # Database Client
Implementation Rules
- Each context is independent - Complete Clean Architecture within
- No shared domain logic - Each context owns its entities/VOs
- Duplicate code between contexts - Avoid coupling
- Communication through services - Never direct domain access
- Minimal shared kernel - Only truly universal (< 5 files)
Tech Stack
For complete backend tech stack details, see project-standards skill
Quick Reference:
- Runtime: Bun
- Framework: Hono (HTTP)
- Database: PostgreSQL + Drizzle ORM
- Cache: Redis (ioredis)
- Queue: AWS SQS (LocalStack local)
- Validation: Zod
- Testing: Vitest
โ Use project-standards skill for comprehensive tech stack information
Backend Architecture (Clean Architecture)
This section provides practical implementation examples. For architectural principles, dependency rules, and testing strategies, see clean-architecture skill
Layers (dependency flow: Infrastructure โ Application โ Domain)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Infrastructure Layer โ
โ (repositories, adapters, container) โ
โ โ
โ โโโ HTTP Layer (framework-specific) โ
โ โ โโโ server/ (Hono adapter) โ
โ โ โโโ controllers/ (self-register) โ
โ โ โโโ schemas/ (Zod validation) โ
โ โ โโโ middleware/ โ
โ โ โโโ plugins/ โ
โโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโ
โ depends on โ
โโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Application Layer โ
โ (use cases, DTOs) โ
โโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโ
โ depends on โ
โโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Domain Layer โ
โ (entities, value objects, ports) โ
โ (NO DEPENDENCIES) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
1. Domain Layer (Core Business Logic)
Contains: Entities, Value Objects, Ports (interfaces), Domain Services
Example: Value Object
// domain/value-objects/email.value-object.ts
export class Email {
private constructor(private readonly value: string) {}
static create(value: string): Email {
if (!value) {
throw new Error("Email is required");
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
throw new Error(`Invalid email format: ${value}`);
}
return new Email(value.toLowerCase());
}
equals(other: Email): boolean {
return this.value === other.value;
}
toString(): string {
return this.value;
}
}
Example: Entity
// domain/entities/user.entity.ts
import type { Email } from "@/domain/value-objects/email.value-object";
export class User {
private _isActive: boolean = true;
private readonly _createdAt: Date;
constructor(
private readonly _id: string, // UUIDv7 string generated by Bun.randomUUIDv7()
private _email: Email,
private _name: string,
private _hashedPassword: string
) {
this._createdAt = new Date();
}
// Domain behavior
deactivate(): void {
if (!this._isActive) {
throw new Error(`User ${this._id} is already inactive`);
}
this._isActive = false;
}
changeEmail(newEmail: Email): void {
if (this._email.equals(newEmail)) {
return;
}
this._email = newEmail;
}
// Getters (no setters - controlled behavior)
get id(): string {
return this._id;
}
get email(): Email {
return this._email;
}
get name(): string {
return this._name;
}
get isActive(): boolean {
return this._isActive;
}
get createdAt(): Date {
return this._createdAt;
}
}
Example: Port (Interface)
// domain/ports/repositories/user.repository.ts
import type { User } from "@/domain/entities/user.entity";
import type { Result } from "@/domain/shared/result";
// NO "I" prefix
export interface UserRepository {
findById(id: string): Promise<Result<User | null>>; // id is UUIDv7 string
findByEmail(email: string): Promise<Result<User | null>>;
save(user: User): Promise<Result<void>>;
update(user: User): Promise<Result<void>>;
delete(id: string): Promise<Result<void>>; // id is UUIDv7 string
}
2. Application Layer (Use Cases)
Contains: Use Cases, DTOs, Mappers
Example: Use Case
// application/use-cases/create-user.use-case.ts
import type { UserRepository } from "@/domain/ports";
import type { CacheService } from "@/domain/ports";
import type { Logger } from "@/domain/ports";
import { User } from "@/domain/entities";
import { Email } from "@/domain/value-objects";
import type { CreateUserDto, UserResponseDto } from "@/application/dtos";
export class CreateUserUseCase {
constructor(
private readonly userRepository: UserRepository,
private readonly cacheService: CacheService,
private readonly logger: Logger
) {}
async execute(dto: CreateUserDto): Promise<UserResponseDto> {
this.logger.info("Creating user", { email: dto.email });
// 1. Validate business rules
const existingUser = await this.userRepository.findByEmail(dto.email);
if (existingUser.isSuccess && existingUser.value) {
throw new Error(`User with email ${dto.email} already exists`);
}
// 2. Create domain objects
const id = Bun.randomUUIDv7(); // Generate UUIDv7 using Bun native API
const email = Email.create(dto.email);
const user = new User(id, email, dto.name, dto.hashedPassword);
// 3. Persist
const saveResult = await this.userRepository.save(user);
if (saveResult.isFailure) {
throw new Error(`Failed to save user: ${saveResult.error}`);
}
// 4. Invalidate cache
await this.cacheService.del(`user:${email.toString()}`);
// 5. Return DTO
return {
id: user.id.toString(),
email: user.email.toString(),
name: user.name,
isActive: user.isActive,
createdAt: user.createdAt.toISOString(),
};
}
}
Example: DTO
// application/dtos/user.dto.ts
import { z } from "zod";
export const createUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(2).max(100),
});
export type CreateUserDto = z.infer<typeof createUserSchema>;
export interface UserResponseDto {
id: string;
email: string;
name: string;
isActive: boolean;
createdAt: string;
}
3. Infrastructure Layer (Technical Implementation)
Contains: Repositories (database), Adapters (external services), Container (DI)
Example: Repository Implementation
// infrastructure/repositories/user.repository.impl.ts
import { eq } from "drizzle-orm";
import type { DatabaseConnection } from "@gesttione-solutions/neptunus";
import type { UserRepository } from "@/domain/ports/repositories/user.repository";
import type { User } from "@/domain/entities/user.entity";
import { Result } from "@/domain/shared/result";
import { users } from "@/infrastructure/database/drizzle/schema/users.schema";
export class UserRepositoryImpl implements UserRepository {
constructor(private readonly db: DatabaseConnection) {}
async findById(id: string): Promise<Result<User | null>> {
// id is UUIDv7 string
try {
const [row] = await this.db
.select()
.from(users)
.where(eq(users.id, id))
.limit(1);
if (!row) {
return Result.ok(null);
}
return Result.ok(this.toDomain(row));
} catch (error) {
return Result.fail(`Failed to find user: ${error}`);
}
}
async save(user: User): Promise<Result<void>> {
try {
await this.db.insert(users).values({
id: user.id, // UUIDv7 string
email: user.email.toString(),
name: user.name,
isActive: user.isActive,
createdAt: user.createdAt,
});
return Result.ok(undefined);
} catch (error) {
return Result.fail(`Failed to save user: ${error}`);
}
}
private toDomain(row: typeof users.$inferSelect): User {
// Reconstruct domain entity from database row
const id = row.id; // UUIDv7 string from database
const email = Email.create(row.email);
return new User(id, email, row.name, row.hashedPassword);
}
}
Example: Adapter (External Service)
// infrastructure/adapters/cache.service.impl.ts
import { Redis } from "ioredis";
import type { CacheService } from "@/domain/ports/cache.service";
import type { EnvConfig } from "@/domain/ports/env-config.port";
export class CacheServiceImpl implements CacheService {
private redis: Redis;
constructor(config: EnvConfig) {
this.redis = new Redis({
host: config.REDIS_HOST,
port: config.REDIS_PORT,
});
}
async set(
key: string,
value: string,
expirationInSeconds?: number
): Promise<void> {
if (expirationInSeconds) {
await this.redis.set(key, value, "EX", expirationInSeconds);
} else {
await this.redis.set(key, value);
}
}
async get(key: string): Promise<string | null> {
return await this.redis.get(key);
}
async del(key: string): Promise<void> {
await this.redis.del(key);
}
async flushAll(): Promise<void> {
await this.redis.flushall();
}
}
4. HTTP Layer (Framework-Specific, in Infrastructure)
Location: infrastructure/http/
Contains: Server, Controllers (self-registering), Schemas (Zod validation), Middleware, Plugins
Example: Schema
// infrastructure/http/schemas/user.schema.ts
import { z } from "zod";
export const createUserRequestSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(2).max(100),
});
export const userResponseSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
isActive: z.boolean(),
createdAt: z.string().datetime(),
});
Example: Self-Registering Controller
// infrastructure/http/controllers/user.controller.ts
import type { HttpServer } from "@/domain/ports/http-server";
import { HttpMethod } from "@/domain/ports/http-server";
import type { CreateUserUseCase } from "@/application/use-cases/create-user.use-case";
import type { GetUserUseCase } from "@/application/use-cases/get-user.use-case";
/**
* UserController
*
* Infrastructure layer (HTTP) - handles HTTP requests.
* Thin layer that delegates to use cases.
*
* Responsibilities:
* 1. Register routes in constructor
* 2. Validate requests (Zod schemas)
* 3. Delegate to use cases
* 4. Format responses (return DTOs)
*
* NO business logic here! Controllers should be thin.
*
* Pattern: Constructor Injection + Auto-registration
*/
export class UserController {
constructor(
private readonly httpServer: HttpServer, // โ
HttpServer port injected
private readonly createUserUseCase: CreateUserUseCase, // โ
Use case injected
private readonly getUserUseCase: GetUserUseCase // โ
Use case injected
) {
this.registerRoutes(); // โ
Auto-register routes in constructor
}
private registerRoutes(): void {
// POST /users - Create new user
this.httpServer.route(HttpMethod.POST, "/users", async (context) => {
try {
const dto = context.req.valid("json"); // Validated by middleware
const user = await this.createUserUseCase.execute(dto);
return context.json(user, 201);
} catch (error) {
console.error("Error creating user:", error);
return context.json({ error: "Internal server error" }, 500);
}
});
// GET /users/:id - Get user by ID
this.httpServer.route(HttpMethod.GET, "/users/:id", async (context) => {
try {
const { id } = context.req.param();
const user = await this.getUserUseCase.execute(id);
return context.json(user, 200);
} catch (error) {
console.error("Error getting user:", error);
return context.json({ error: "User not found" }, 404);
}
});
}
}
Example: HttpServer Port (Domain Layer)
// domain/ports/http-server.ts
export enum HttpMethod {
GET = "GET",
POST = "POST",
PUT = "PUT",
DELETE = "DELETE",
PATCH = "PATCH",
}
export type HttpHandler = (context: unknown) => Promise<Response | unknown>;
export interface HttpServer {
route(method: HttpMethod, url: string, handler: HttpHandler): void;
listen(port: number): void;
}
Example: HonoHttpServer Implementation (Infrastructure Layer)
// infrastructure/http/server/hono-http-server.adapter.ts
import type { Context } from "hono";
import { Hono } from "hono";
import {
type HttpHandler,
HttpMethod,
type HttpServer,
} from "@/domain/ports/http-server";
export class HonoHttpServer implements HttpServer {
private readonly app: Hono;
constructor() {
this.app = new Hono();
}
route(method: HttpMethod, url: string, handler: HttpHandler): void {
const honoHandler = async (c: Context) => {
try {
const result = await handler(c);
return result instanceof Response ? result : (result as Response);
} catch (error) {
console.error("Error handling request:", error);
return c.json({ error: "Internal server error" }, 500);
}
};
switch (method) {
case HttpMethod.GET:
this.app.get(url, honoHandler);
break;
case HttpMethod.POST:
this.app.post(url, honoHandler);
break;
case HttpMethod.PUT:
this.app.put(url, honoHandler);
break;
case HttpMethod.DELETE:
this.app.delete(url, honoHandler);
break;
case HttpMethod.PATCH:
this.app.patch(url, honoHandler);
break;
default:
throw new Error(`Unsupported HTTP method: ${method}`);
}
}
listen(port: number): void {
console.log(`Server is running on http://localhost:${port}`);
Bun.serve({
fetch: this.app.fetch,
port,
});
}
getApp(): Hono {
return this.app;
}
}
Example: Bootstrap (Entry Point)
// main.ts
import { getAppContainer, TOKENS } from "@/infrastructure/di";
const DEFAULT_PORT = 3000;
/**
* Application Bootstrap
*
* 1. Get application container (DI)
* 2. Initialize controllers (they auto-register routes in constructor)
* 3. Start HTTP server
*/
async function bootstrap() {
// Get application container (singleton)
const container = getAppContainer();
// Initialize controllers (they auto-register routes in constructor)
container.resolve(TOKENS.systemController);
container.resolve(TOKENS.userController);
// Resolve and start HTTP server
const server = container.resolve(TOKENS.httpServer);
const port = Number(process.env.PORT) || DEFAULT_PORT;
server.listen(port);
}
// Entry point with error handling
bootstrap().catch((error) => {
console.error("Failed to start server:", error);
process.exit(1);
});
Key Benefits:
- โ Thin controllers - Only route registration + delegation
- โ Auto-registration - Controllers register themselves in constructor
- โ Framework-agnostic domain - HttpServer port in domain layer
- โ Testable - Easy to mock HttpServer for testing controllers
- โ DI-friendly - Controllers resolve via container
- โ Clean separation - No routes/ folder needed
- โ Single responsibility - Controllers only handle HTTP, business logic in use cases
Dependency Injection Container
Container Implementation
// infrastructure/container/container.ts
export type Lifetime = "singleton" | "scoped" | "transient";
export type Token<T> = symbol & { readonly __type?: T };
export interface Provider<T> {
lifetime: Lifetime;
useValue?: T;
useFactory?: (c: Container) => T;
}
export class Container {
private readonly registry: Map<Token<unknown>, Provider<unknown>>;
private readonly singletons: Map<Token<unknown>, unknown>;
private readonly scopedCache: Map<Token<unknown>, unknown>;
private constructor(
registry: Map<Token<unknown>, Provider<unknown>>,
singletons: Map<Token<unknown>, unknown>,
scopedCache?: Map<Token<unknown>, unknown>
) {
this.registry = registry;
this.singletons = singletons;
this.scopedCache = scopedCache ?? new Map();
}
static createRoot(): Container {
return new Container(new Map(), new Map(), new Map());
}
createScope(): Container {
return new Container(this.registry, this.singletons, new Map());
}
register<T>(token: Token<T>, provider: Provider<T>): void {
if (this.registry.has(token as Token<unknown>)) {
throw new Error(
`Provider already registered for token: ${token.description}`
);
}
this.registry.set(token as Token<unknown>, provider as Provider<unknown>);
}
resolve<T>(token: Token<T>): T {
const provider = this.registry.get(token as Token<unknown>);
if (!provider) {
throw new Error(`No provider registered for token: ${token.description}`);
}
// useValue
if ("useValue" in provider && provider.useValue !== undefined) {
return provider.useValue as T;
}
// singleton cache
if (provider.lifetime === "singleton") {
if (this.singletons.has(token as Token<unknown>)) {
return this.singletons.get(token as Token<unknown>) as T;
}
const instance = (provider as Provider<T>).useFactory!(this);
this.singletons.set(token as Token<unknown>, instance);
return instance;
}
// scoped cache
if (provider.lifetime === "scoped") {
if (this.scopedCache.has(token as Token<unknown>)) {
return this.scopedCache.get(token as Token<unknown>) as T;
}
const instance = (provider as Provider<T>).useFactory!(this);
this.scopedCache.set(token as Token<unknown>, instance);
return instance;
}
// transient
return (provider as Provider<T>).useFactory!(this);
}
}
Tokens Definition
// infrastructure/container/tokens.ts
import type { UserRepository } from "@/domain/ports/repositories/user.repository";
import type { CacheService } from "@/domain/ports/cache.service";
import type { Logger } from "@/domain/ports/logger.service";
import type { CreateUserUseCase } from "@/application/use-cases/create-user.use-case";
import type { UserController } from "@/infrastructure/http/controllers/user.controller";
export const TOKENS = {
// Core
Logger: Symbol("Logger") as Token<Logger>,
Config: Symbol("Config") as Token<EnvConfig>,
DatabaseConnection: Symbol("DatabaseConnection") as Token<DatabaseConnection>,
// Repositories
UserRepository: Symbol("UserRepository") as Token<UserRepository>,
// Services
CacheService: Symbol("CacheService") as Token<CacheService>,
// Use Cases
CreateUserUseCase: Symbol("CreateUserUseCase") as Token<CreateUserUseCase>,
// Controllers
UserController: Symbol("UserController") as Token<UserController>,
} as const;
Registration Functions
// infrastructure/container/registers/register.infrastructure.ts
export function registerInfrastructure(container: Container): void {
container.register(TOKENS.Logger, {
lifetime: "singleton",
useValue: logger,
});
container.register(TOKENS.DatabaseConnection, {
lifetime: "singleton",
useValue: dbConnection,
});
container.register(TOKENS.Config, {
lifetime: "singleton",
useValue: Config.getInstance().env,
});
}
// infrastructure/container/registers/register.repositories.ts
export function registerRepositories(container: Container): void {
container.register(TOKENS.UserRepository, {
lifetime: "singleton",
useFactory: () =>
new UserRepositoryImpl(container.resolve(TOKENS.DatabaseConnection)),
});
}
// infrastructure/container/registers/register.use-cases.ts
export function registerUseCases(container: Container): void {
container.register(TOKENS.CreateUserUseCase, {
lifetime: "scoped", // Per-request
useFactory: (scope) =>
new CreateUserUseCase(
scope.resolve(TOKENS.UserRepository),
scope.resolve(TOKENS.CacheService),
scope.resolve(TOKENS.Logger)
),
});
}
// infrastructure/container/registers/register.controllers.ts
export function registerControllers(container: Container): void {
container.register(TOKENS.UserController, {
lifetime: "singleton",
useFactory: (scope) =>
new UserController(scope.resolve(TOKENS.CreateUserUseCase)),
});
}
Composition Root
// infrastructure/container/main.ts
export function createRootContainer(): Container {
const c = Container.createRoot();
registerInfrastructure(c);
registerRepositories(c);
registerUseCases(c);
registerControllers(c);
return c;
}
let rootContainer: Container | null = null;
export function getAppContainer(): Container {
if (!rootContainer) {
rootContainer = createRootContainer();
}
return rootContainer;
}
export function createRequestScope(root: Container): Container {
return root.createScope();
}
Usage in Hono App
// infrastructure/http/app.ts
import { Hono } from "hono";
import {
getAppContainer,
createRequestScope,
} from "@/infrastructure/container/main";
import { TOKENS } from "@/infrastructure/container/tokens";
// Note: With self-registering controllers, route registration is handled by controllers themselves
const app = new Hono();
// Middleware: Create scoped container per request
app.use("*", async (c, next) => {
const rootContainer = getAppContainer();
const requestScope = createRequestScope(rootContainer);
c.set("container", requestScope);
await next();
});
// Register routes
const userController = app.get("container").resolve(TOKENS.UserController);
registerUserRoutes(app, userController);
export default app;
Best Practices
โ Do:
- Keep domain layer pure - No external dependencies
- Use interfaces (ports) - All external dependencies behind ports
- Rich domain models - Entities with behavior, not just data
- Use cases orchestrate - Don't put business logic in controllers
- Inject dependencies - Constructor injection via DI container
- Symbol-based tokens - Type-safe DI tokens
- Scoped use cases - Per-request instances
- Singleton repositories - Stateless, thread-safe
- Result type - For expected failures (not exceptions)
โ Don't:
- Anemic domain models - Entities shouldn't be just data bags
- Business logic in controllers - Controllers should be thin
- Domain depending on infrastructure - Breaks dependency rule
- Skip interfaces - Always use ports for external dependencies
- Use concrete implementations in use cases - Depend on abstractions
- Manual DI - Use the container
- External DI libraries - Use custom container (InversifyJS, TSyringe)
Common Patterns
For complete error handling patterns (Result/Either types, Exception Hierarchy, Retry Logic, Circuit Breaker, Validation Strategies), see error-handling-patterns skill
Domain Events
// domain/events/user-created.event.ts
export class UserCreatedEvent {
constructor(
public readonly userId: string,
public readonly email: string,
public readonly occurredAt: Date = new Date()
) {}
}
// In Use Case
async execute(dto: CreateUserDto): Promise<UserResponseDto> {
// ... create user ...
await this.eventBus.publish(new UserCreatedEvent(user.id.toString(), user.email.toString()));
return response;
}
Remember
- Clean Architecture is about maintainability, not perfection
- The Dependency Rule is sacred - Always point inward
- Domain is the core - Everything revolves around it
- Test domain first - It's the most important part
- Use custom DI container - No external libraries
- Symbol-based tokens - Type-safe dependency injection
- Scoped lifetimes for use cases - Per-request isolation
Repository
