CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/separation-of-concerns

Enforce strict three-layer architecture: thin HTTP routes, pure service logic with domain errors, isolated data access with dependency injection.

94

1.08x
Quality

93%

Does it follow best practices?

Impact

97%

1.08x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files
name:
separation-of-concerns
description:
Enforce strict three-layer architecture: thin HTTP routes, pure service logic, isolated data access. Use when building or refactoring any web API — when route handlers contain database queries, when business logic is mixed with HTTP parsing, when services reference req/res, when error handling crosses layer boundaries, or when you can't test logic without spinning up a server.
keywords:
separation of concerns, clean architecture, service layer, thin controllers, data access layer, repository pattern, business logic, dependency injection, testability, coupling, cohesion, single responsibility, error mapping, domain errors, validation boundaries
license:
MIT

Separation of Concerns

Keep HTTP handling, business logic, and data access in strictly separate layers with clean boundaries between them.


Three-Layer Architecture

HTTP Layer (routes/controllers)
  ↓ passes plain data (not req/res)
Service Layer (business logic)
  ↓ calls via interface/module
Data Layer (repositories/stores)

Layer Responsibilities — Strict Rules

LayerMUST doMUST NOT do
Routes/ControllersParse request body/params/query into plain objects; call service with plain data; map domain errors to HTTP status codes; format response body; set status codesContain SQL/ORM calls; contain business rules; pass req/res/ctx to services; catch domain errors generically
ServicesEnforce business invariants; orchestrate multi-step workflows; emit domain events; throw typed domain errors; accept and return plain data/domain objectsReference req/res/request/response/ctx/context; import HTTP framework modules; contain SQL/ORM queries; set HTTP status codes; format HTTP responses
Data/RepositoryExecute SQL/ORM queries; manage transactions; map DB rows to domain objects; handle connection poolingContain business logic or validation; reference HTTP objects; throw HTTP-specific errors; make business decisions based on data

Critical Pattern: Error Mapping at Boundaries

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.


Critical Pattern: Separate Data Layer Module

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:

  1. The data layer returns null for missing items. The service decides whether that's an error (business logic).
  2. Repositories MUST map raw database rows to typed domain objects — never return raw rows. Use an explicit mapping function (e.g., mapToUser(row)) that translates column names (snake_case) to domain field names (camelCase) and converts types (e.g., string timestamps to Date objects).
  3. Even with in-memory stores, create the repository as a separate module with the same interface it would have with a real database.

Critical Pattern: Constructor/Parameter Injection for Testability

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,
});

Critical Pattern: Validation Belongs at Two Levels

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);
}

Critical Pattern: Side Effects in Services, Not Routes

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;
}

Critical Pattern: Avoid God Services

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 generation

Each service gets its own repository dependencies and handles one cohesive domain area.


File Organization

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.ts

Quick Checklist

  • Route handlers ONLY: parse input, call service with plain data, map errors to HTTP status, format response
  • Services accept and return plain objects/domain types — never req/res/ctx
  • Services throw domain-specific error types — never HTTP status codes
  • Routes map domain errors to appropriate HTTP status codes
  • All SQL/ORM queries live in repository/store modules — never in services or routes
  • Repositories return domain objects or null — never make business decisions
  • Side effects (email, events, notifications) triggered from service layer
  • Each service focuses on one domain area — no god services
  • Dependencies are injectable for testing (constructor/factory pattern)
  • Input shape validation in routes; business rule validation in services

Verifiers

  • no-http-in-services — Service layer must not reference HTTP objects (req, res, request, response)
  • thin-route-handlers — Keep route handlers thin — no SQL or business logic in routes
  • domain-errors-not-http — Services throw domain errors, routes map them to HTTP status codes
  • separate-data-layer — Data access lives in dedicated repository/store modules, not in services
  • injectable-dependencies — Services accept dependencies via injection for testability
Workspace
tessl-labs
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
tessl-labs/separation-of-concerns badge