Service Layer

Thin Controllers, Fat Services, Easier Tests

Service Layer

Separate business logic and data access from Koa controller functions by introducing a service layer, keeping controllers thin and logic independently testable.

4 min read Level 2/5 #koa#services#architecture
What you'll learn
  • Explain the separation of concerns between controllers and services
  • Create a service module that encapsulates data access and business rules
  • Call service functions from a controller and pass results to ctx.body

A controller’s job is to translate HTTP into function calls and function results back into HTTP. Business logic — validation rules, calculations, database queries — belongs in a service. This split keeps each layer small, focused, and independently testable.

The Layered Structure

src/
  controllers/   ← HTTP layer (ctx in, ctx out)
  services/      ← business logic (plain JS, no ctx)
  models/        ← data access (DB queries, ORM)

Controllers call services; services call models (or a database client directly). Neither services nor models import anything from Koa.

Writing a Service

// src/services/articles.js
import { db } from "../db.js";         // your DB client / ORM

export async function findAll({ limit = 20, offset = 0 } = {}) {
  return db("articles").select("*").limit(limit).offset(offset);
}

export async function findById(id) {
  const row = await db("articles").where({ id }).first();
  if (!row) throw Object.assign(new Error("Not found"), { status: 404 });
  return row;
}

export async function create(data) {
  const [row] = await db("articles").insert(data).returning("*");
  return row;
}

export async function update(id, data) {
  await findById(id);                  // throws 404 if missing
  const [row] = await db("articles")
    .where({ id })
    .update(data)
    .returning("*");
  return row;
}

export async function remove(id) {
  await findById(id);
  await db("articles").where({ id }).delete();
}

Notice that services throw plain Error objects (optionally with a .status property) — they are not aware of ctx.

Thin Controller

// src/controllers/articles.js
import * as ArticleService from "../services/articles.js";

export async function listArticles(ctx) {
  ctx.body = await ArticleService.findAll(ctx.query);
}

export async function getArticle(ctx) {
  ctx.body = await ArticleService.findById(ctx.params.id);
}

export async function createArticle(ctx) {
  ctx.status = 201;
  ctx.body = await ArticleService.create(ctx.request.body);
}

export async function deleteArticle(ctx) {
  await ArticleService.remove(ctx.params.id);
  ctx.status = 204;
}

Each controller action is now a one-liner. The service carries all the logic, so you can test it with real or mock DB fixtures without touching Koa at all.

Error Bubbling

Service errors propagate up through the controller. A central error-handling middleware (covered later in this section) inspects err.status and builds the JSON error response — meaning individual controllers never need try/catch for expected errors.

Up Next

Services should throw early when input is invalid. The next lesson adds a validation layer using Zod before data even reaches the service.

Input Validation →