Onion Architecture was coined by Jeffrey Palermo in 2008 to solve a problem he kept seeing: layered (“N-tier”) applications where the domain model quietly depended on the database layer, so you couldn’t change one without breaking the other.

This note strips it to the essentials: the rings, the one rule that holds them together, and one real feature — placing an order in a small API — built the onion way in Node.js. It reads in about 10–15 minutes.


The one-sentence definition

Onion Architecture arranges code in concentric rings around a domain model at the center, where every dependency points inward — outer rings (UI, database, frameworks) may depend on inner rings, but an inner ring never depends on an outer one.

Everything below is a consequence of that sentence.


Why rings?

Peel an onion and you always reach the same core, no matter which layer you cut through. That’s the metaphor: no matter how many layers surround it, the domain model at the center stays untouched by the outside world.

Domain Model Domain Services rules across entities Application Services use cases + interfaces (ports) Infrastructure & UI Express, Postgres, email — implements interfaces dependencies point inward
Every arrow points toward the center. The Domain Model imports nothing from the rings around it.
Ring (center → edge) Contains Depends on
Domain Model Entities, value objects — pure data and invariants. Nothing.
Domain Services Business rules that span more than one entity. Domain Model only.
Application Services Use cases, and the interfaces (ports) those use cases need from the outside. Domain layers only — never a concrete database or framework.
Infrastructure & UI Express routes, Postgres repositories, email senders — everything that implements an interface or talks to the world. Everything inward. This is the only ring allowed to require('express') or require('pg').

If this sounds like Hexagonal or Clean Architecture — it should. Onion, Hexagonal, and Clean Architecture are siblings sharing the same Dependency Rule: source code dependencies point inward, and the innermost layer knows nothing about the outermost. Onion’s distinguishing feature is naming the inward layers explicitly as rings (Domain Model → Domain Services → Application Services) rather than just “core vs. adapters.”


Real-world use case: “Place an order”

A customer submits a cart. The system must: verify every product is in stock, calculate the total with any discount, save the order, and send a confirmation email. We’ll build it in Node.js, ring by ring, from the center out.

1. Domain Model — entities and invariants, nothing else

// domain/model/order.js
class Order {
  constructor(id, customerId, items) {
    if (items.length === 0) {
      throw new Error('An order must have at least one item');
    }
    this.id = id;
    this.customerId = customerId;
    this.items = items; // [{ productId, quantity, unitPrice }]
    this.status = 'PENDING';
  }

  total() {
    return this.items.reduce((sum, i) => sum + i.quantity * i.unitPrice, 0);
  }

  confirm() {
    this.status = 'CONFIRMED';
  }
}

module.exports = { Order };

Order knows what makes an order valid and how to total itself. It has never heard of Postgres, Express, or JSON.

2. Domain Services — rules that span more than one entity

// domain/services/pricing.js

// A domain service belongs here — not on Order itself — because
// discounting needs the Customer entity too, not just the Order.
function applyLoyaltyDiscount(order, customer) {
  const discountRate = customer.loyaltyTier === 'GOLD' ? 0.1 : 0;
  return order.total() * (1 - discountRate);
}

module.exports = { applyLoyaltyDiscount };

Still no I/O. applyLoyaltyDiscount is pure: same inputs, same output, every time.

3. Application Services — the use case, and the ports it needs

// application/ports.js

// Ports are documented shapes (Node has no interfaces) that the
// use case depends on. Infrastructure adapters implement them.
//   ProductCatalog: { checkStock(items) }
//   OrderRepository: { save(order) }
//   Notifier:        { orderConfirmed(order) }

module.exports = {}; // shapes only, not enforced classes
// application/placeOrder.js
const { randomUUID } = require('crypto');
const { Order } = require('../domain/model/order');
const { applyLoyaltyDiscount } = require('../domain/services/pricing');

class OutOfStock extends Error {}

class PlaceOrder {
  // Dependencies arrive as ports: catalog, repository, notifier.
  constructor({ productCatalog, orderRepository, notifier }) {
    this.productCatalog = productCatalog;
    this.orderRepository = orderRepository;
    this.notifier = notifier;
  }

  async handle({ customerId, items, customer }) {
    const inStock = await this.productCatalog.checkStock(items);
    if (!inStock) {
      throw new OutOfStock('One or more items are unavailable');
    }

    const order = new Order(randomUUID(), customerId, items);
    order.finalTotal = applyLoyaltyDiscount(order, customer);
    order.confirm();

    await this.orderRepository.save(order);
    await this.notifier.orderConfirmed(order);

    return order;
  }
}

module.exports = { PlaceOrder, OutOfStock };

Read handle() top to bottom and it is the business process: check stock, price it, confirm it, save it, notify. No req, no SQL, no SMTP client. Swap Postgres for MongoDB or SendGrid for a queue — this class does not change.

Express route PlaceOrder use case ProductCatalog OrderRepository Notifier InventoryApi PgOrderRepo MailNotifier
The use case only depends on the dashed ports it defines. Green arrows are infrastructure classes implementing them — pointing inward.

4. Infrastructure — implement the ports

Product catalog (calls an internal inventory service):

// infrastructure/inventoryApiCatalog.js
class InventoryApiCatalog {
  constructor(httpClient) {
    this.httpClient = httpClient; // e.g. axios instance
  }

  async checkStock(items) {
    const { data } = await this.httpClient.post('/inventory/check', { items });
    return data.allInStock;
  }
}

module.exports = { InventoryApiCatalog };

Order repository (Postgres, using pg):

// infrastructure/pgOrderRepository.js
class PgOrderRepository {
  constructor(pool) {
    this.pool = pool; // node-postgres Pool
  }

  async save(order) {
    await this.pool.query(
      `INSERT INTO orders (id, customer_id, total, status)
       VALUES ($1, $2, $3, $4)`,
      [order.id, order.customerId, order.finalTotal, order.status],
    );
  }
}

module.exports = { PgOrderRepository };

Notifier (nodemailer):

// infrastructure/mailNotifier.js
class MailNotifier {
  constructor(transporter) {
    this.transporter = transporter; // nodemailer transport
  }

  async orderConfirmed(order) {
    await this.transporter.sendMail({
      to: order.customerId,
      from: 'orders@example.com',
      subject: 'Order confirmed',
      text: `Your order ${order.id} totals ${order.finalTotal}.`,
    });
  }
}

module.exports = { MailNotifier };

Each class is small and replaceable — exactly what you want for the outermost ring.

5. UI — an Express route

// infrastructure/httpRoutes.js
const express = require('express');
const { OutOfStock } = require('../application/placeOrder');

function buildRouter(placeOrder) {
  const router = express.Router();

  router.post('/orders', async (req, res) => {
    try {
      const order = await placeOrder.handle({
        customerId: req.body.customerId,
        items: req.body.items,
        customer: req.body.customer,
      });
      res.status(201).json({ id: order.id, total: order.finalTotal });
    } catch (err) {
      if (err instanceof OutOfStock) {
        return res.status(409).json({ error: err.message });
      }
      res.status(400).json({ error: err.message });
    }
  });

  return router;
}

module.exports = { buildRouter };

The route turns HTTP into a plain object, calls the use case, turns the result back into HTTP — same job a CLI command or a test would each do differently while calling the exact same PlaceOrder.handle().

6. Wiring — the composition root

// index.js
const express = require('express');
const axios = require('axios');
const { Pool } = require('pg');
const nodemailer = require('nodemailer');

const { PlaceOrder } = require('./application/placeOrder');
const { InventoryApiCatalog } = require('./infrastructure/inventoryApiCatalog');
const { PgOrderRepository } = require('./infrastructure/pgOrderRepository');
const { MailNotifier } = require('./infrastructure/mailNotifier');
const { buildRouter } = require('./infrastructure/httpRoutes');

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const httpClient = axios.create({ baseURL: process.env.INVENTORY_URL });
const transporter = nodemailer.createTransport({ /* smtp config */ });

const placeOrder = new PlaceOrder({
  productCatalog: new InventoryApiCatalog(httpClient),
  orderRepository: new PgOrderRepository(pool),
  notifier: new MailNotifier(transporter),
});

const app = express();
app.use(express.json());
app.use(buildRouter(placeOrder));

app.listen(3000, () => console.log('Listening on :3000'));

This is the only file that knows about Express, pg, axios, and nodemailer and about the domain at the same time. Every ring inward from here is invisible to it.


Why this pays off: testing without a database

Because PlaceOrder only depends on port shapes, a test hands it plain in-memory fakes — no Express, no Postgres, no network:

// test/placeOrder.test.js
const { PlaceOrder, OutOfStock } = require('../application/placeOrder');

test('rejects an order when stock is unavailable', async () => {
  const placeOrder = new PlaceOrder({
    productCatalog: { checkStock: async () => false },
    orderRepository: { save: async () => {} },
    notifier: { orderConfirmed: async () => {} },
  });

  await expect(
    placeOrder.handle({
      customerId: 'c1',
      items: [{ productId: 'p1', quantity: 1, unitPrice: 10 }],
      customer: { loyaltyTier: 'STANDARD' },
    }),
  ).rejects.toBeInstanceOf(OutOfStock);
});

This runs in milliseconds and never touches a real database — the use case never asked for one, only for a ProductCatalog-shaped object.


The traps

Mistake Why it hurts
Putting an ORM entity (e.g. a Sequelize model) at the center as the “domain model” Now the innermost ring depends on the ORM. Changing the ORM means rewriting entities.
Reaching from Domain Services into req, res, or environment variables Breaks the inward-only rule silently — the ring boundary exists on paper but not in the code.
Application Services importing a concrete adapter directly (require('../infrastructure/pgOrderRepository')) Defeats the point of defining a port — the use case is now locked to Postgres.
One “services” folder mixing domain services and application services Blurs which rules are pure business logic and which are orchestration — makes the dependency rule hard to audit later.
Applying this to a five-endpoint prototype Four rings and several interfaces is overhead you don’t need yet.

When to reach for it

Onion Architecture is an investment: more files, more interfaces, more indirection. It earns that cost when:

  • the domain rules are the most valuable, longest-lived part of the system — outlasting today’s database or framework;
  • you need fast, database-free tests for that domain logic;
  • you expect to swap infrastructure — database, message broker, email provider — without touching business rules;
  • more than one domain service or use case shares the same entities, so a clear inward dependency rule keeps them from becoming tangled.

For a small CRUD script, skip it. For the core of a service you’ll run and evolve for years, the rings are worth the extra files.


A five-point checklist

  1. Keep the Domain Model free of library imports. No ORM base classes, no express, no SDK types inside entities.
  2. Push cross-entity rules into Domain Services, not into a controller. They stay pure, no I/O.
  3. Define ports in the Application layer, next to the use case that needs them. The interface lives with the consumer, not the infrastructure.
  4. Let Infrastructure implement ports, never the reverse. An inner ring must never require() an outer one.
  5. Wire everything in one composition root (index.js) and nowhere else.

Conclusion

Onion Architecture is one idea drawn as rings: the Domain Model sits untouched at the center, and every ring around it may depend inward but never outward. In Node.js this costs nothing exotic — plain classes for entities, constructor injection for use cases, and the discipline to keep require('pg') and require('express') out of the inner rings.

The payoff is a domain you can test in milliseconds, infrastructure you can swap without fear, and a codebase where “what does placing an order actually do” has one obvious, framework-free answer.