Enforce strict three-layer architecture: thin HTTP routes, pure service logic with domain errors, isolated data access with dependency injection.
94
93%
Does it follow best practices?
Impact
97%
1.08xAverage score across 5 eval scenarios
Passed
No known issues
Keep HTTP handling, business logic, and data access in strictly separate layers with clean boundaries between them.
HTTP Layer (routes/controllers)
↓ passes plain data (not req/res)
Service Layer (business logic)
↓ calls via interface/module
Data Layer (repositories/stores)| Layer | MUST do | MUST NOT do |
|---|---|---|
| Routes/Controllers | Parse request body/params/query into plain objects; call service with plain data; map domain errors to HTTP status codes; format response body; set status codes | Contain SQL/ORM calls; contain business rules; pass req/res/ctx to services; catch domain errors generically |
| Services | Enforce business invariants; orchestrate multi-step workflows; emit domain events; throw typed domain errors; accept and return plain data/domain objects | Reference req/res/request/response/ctx/context; import HTTP framework modules; contain SQL/ORM queries; set HTTP status codes; format HTTP responses |
| Data/Repository | Execute SQL/ORM queries; manage transactions; map DB rows to domain objects; handle connection pooling | Contain business logic or validation; reference HTTP objects; throw HTTP-specific errors; make business decisions based on data |
The route layer is responsible for translating domain errors into HTTP responses. Services must throw domain-specific errors, never HTTP errors.
// errors/domain.ts — Domain errors (framework-agnostic)
class NotFoundError extends Error {
constructor(public entity: string, public id: string) {
super(`${entity} not found: ${id}`);
}
}
class ValidationError extends Error {
constructor(public field: string, public reason: string) {
super(`${field}: ${reason}`);
}
}
class BusinessRuleError extends Error {
constructor(public rule: string) {
super(`Business rule violated: ${rule}`);
}
}
// routes/orders.ts — HTTP layer maps domain errors to HTTP
router.post("/api/orders", asyncHandler(async (req, res) => {
try {
const order = await orderService.createOrder(req.body);
res.status(201).json({ data: order });
} catch (err) {
if (err instanceof ValidationError) {
res.status(400).json({ error: err.message, field: err.field });
} else if (err instanceof NotFoundError) {
res.status(404).json({ error: err.message });
} else if (err instanceof BusinessRuleError) {
res.status(422).json({ error: err.message });
} else {
throw err; // IMPORTANT: always re-throw unexpected errors — never swallow them with a generic 500
}
}
}));Critical: Always re-throw unexpected errors. If the route catches an error that is not a known domain error, it must re-throw (or call next(err)) so that error middleware can log it and return a proper 500. Never catch all errors with a generic res.status(500).json({ error: "internal error" }) — this hides bugs.
// services/orderService.ts — Throws domain errors, never HTTP errors
async function createOrder(input: CreateOrderInput): Promise<Order> {
if (!input.customerName?.trim()) throw new ValidationError("customerName", "required");
if (!input.items?.length) throw new ValidationError("items", "must not be empty");
const total = await calculateTotal(input.items);
if (total <= 0) throw new BusinessRuleError("order total must be positive");
const order = await orderRepo.create(input.customerName, input.items, total);
eventBus.emit("order:created", order);
return order;
}Why this matters: If a service throws new Error("404: not found") or sets status codes, it cannot be reused from background jobs, CLI tools, or other services.
Always create a distinct data access module — do not put SQL/ORM queries directly in services even when it seems simpler.
// BAD — SQL in service (common mistake)
// services/userService.ts
async function getUser(id: string): Promise<User> {
const row = await db.query("SELECT * FROM users WHERE id = $1", [id]);
if (!row) throw new NotFoundError("user", id);
return row;
}
// GOOD — Service delegates to repository
// repositories/userRepo.ts — Data access
async function findById(id: string): Promise<User | null> {
const row = await db.query("SELECT * FROM users WHERE id = $1", [id]);
return row ? mapToUser(row) : null;
}
function mapToUser(row: any): User {
return { id: row.id, name: row.name, email: row.email, createdAt: new Date(row.created_at) };
}
// services/userService.ts — Business logic only
async function getUser(id: string): Promise<User> {
const user = await userRepo.findById(id);
if (!user) throw new NotFoundError("user", id);
return user;
}Key rules:
null for missing items. The service decides whether that's an error (business logic).mapToUser(row)) that translates column names (snake_case) to domain field names (camelCase) and converts types (e.g., string timestamps to Date objects).Services should receive their dependencies rather than importing them directly. This makes testing without real databases possible.
// services/orderService.ts — Dependencies injected
export function createOrderService(deps: {
orderRepo: OrderRepo;
menuRepo: MenuRepo;
eventBus: EventBus;
}) {
return {
async createOrder(input: CreateOrderInput): Promise<Order> {
// ... uses deps.orderRepo, deps.menuRepo, deps.eventBus
}
};
}
// Wiring in app setup
const orderService = createOrderService({
orderRepo: new PgOrderRepo(db),
menuRepo: new PgMenuRepo(db),
eventBus: appEventBus,
});
// In tests — easy to mock
const orderService = createOrderService({
orderRepo: mockOrderRepo,
menuRepo: mockMenuRepo,
eventBus: mockEventBus,
});Input shape validation belongs in the route layer — check that required fields exist, that strings are non-empty, that numbers are actually numbers, that emails contain '@'. Return 400 immediately for malformed input, before calling the service. Business rule validation (is this user allowed? is the balance sufficient? is the item in stock?) belongs in the service layer.
// routes/accounts.ts — Input shape validation (HTTP layer)
router.post("/api/transfers", asyncHandler(async (req, res) => {
const { fromAccountId, toAccountId, amount } = req.body;
// Shape validation — is the input well-formed?
if (!fromAccountId || !toAccountId) {
return res.status(400).json({ error: "both account IDs required" });
}
if (typeof amount !== "number" || amount <= 0) {
return res.status(400).json({ error: "amount must be a positive number" });
}
try {
const transfer = await transferService.execute({ fromAccountId, toAccountId, amount });
res.status(201).json({ data: transfer });
} catch (err) {
// Map domain errors to HTTP
if (err instanceof InsufficientFundsError) res.status(422).json({ error: err.message });
else if (err instanceof NotFoundError) res.status(404).json({ error: err.message });
else throw err;
}
}));
// services/transferService.ts — Business rule validation (service layer)
async function execute(input: TransferInput): Promise<Transfer> {
const fromAccount = await accountRepo.findById(input.fromAccountId);
if (!fromAccount) throw new NotFoundError("account", input.fromAccountId);
// Business rule — only the service knows the balance constraint
if (fromAccount.balance < input.amount) {
throw new InsufficientFundsError(input.fromAccountId, fromAccount.balance, input.amount);
}
// Business rule — daily transfer limit
const todayTotal = await transferRepo.sumTodayByAccount(input.fromAccountId);
if (todayTotal + input.amount > DAILY_TRANSFER_LIMIT) {
throw new BusinessRuleError("daily transfer limit exceeded");
}
return await transferRepo.create(input);
}Email, notifications, analytics, webhooks, and event publishing belong in the service layer — never in route handlers.
// BAD — side effects in route
router.post("/api/users", asyncHandler(async (req, res) => {
const user = await userService.register(req.body);
await emailService.sendWelcome(user.email); // WRONG — side effect in route
analytics.track("user_registered", user.id); // WRONG — side effect in route
res.status(201).json({ data: user });
}));
// GOOD — side effects in service
// services/userService.ts
async function register(input: RegisterInput): Promise<User> {
// ... validation and creation ...
const user = await userRepo.create(input);
await emailService.sendWelcome(user.email); // Side effect in service
eventBus.emit("user:registered", user); // Other services can react
return user;
}When a service grows beyond ~200 lines or handles unrelated concerns, split it.
// BAD — God service doing everything
class UserService {
register() { ... }
login() { ... }
resetPassword() { ... }
updateProfile() { ... }
uploadAvatar() { ... }
calculateSubscriptionTier() { ... }
processPayment() { ... }
generateInvoice() { ... }
}
// GOOD — Focused services
// services/authService.ts — Authentication concerns
// services/profileService.ts — Profile management
// services/billingService.ts — Subscription and payment
// services/invoiceService.ts — Invoice generationEach service gets its own repository dependencies and handles one cohesive domain area.
src/
routes/ # HTTP layer — thin handlers
orders.ts
users.ts
services/ # Business logic — no HTTP, no SQL
orderService.ts
userService.ts
repositories/ # Data access — SQL/ORM, returns domain objects
orderRepo.ts
userRepo.ts
errors/ # Domain error classes (NOT HTTP errors)
domain.ts
types/ # Shared interfaces/types
order.ts
user.tsreq/res/ctxevals
scenario-1
scenario-2
scenario-3
scenario-4
scenario-5
skills
separation-of-concerns
verifiers