Skip to main content
HomeBlogServicesLinks
RSS

Ports and Adapters Pattern: Isolating Domain Logic from Databases and APIs

29 Jun 2026
  • Architecture
  • Learning
  • Engineering
  • Tech

Table of Contents

  • Introduction
  • The Mess We're All Writing
  • What is the Domain?
  • What is I/O?
  • The Boundary: Ports and Adapters Explained
  • Before vs After
  • Three Rules to Live By
  • Quick Recap
  • Reference

Introduction

Hey devs, welcome to the very first post in the Architecture Patterns series under My Tech Pills.

Design patterns help you solve problems at the object level. Architecture patterns help you solve problems at a bigger level - how your layers talk to each other, what depends on what, and where your business logic actually lives.

This first one is foundational. Get this right and everything else like testing, swapping infrastructure and onboarding teammates becomes significantly easier.

We're talking about the Ports and Adapters Pattern, also known as Hexagonal Architecture.

The Mess We're All Writing

Let's start honest.

Most backend codebases including ones written by mid-level engineers look something like this:

function placeOrder(customerId, itemId, quantity) {

  const item = db.query("SELECT * FROM items WHERE id = ?", itemId)
  const customer = db.query("SELECT * FROM customers WHERE id = ?", customerId)

  if (item.stock < quantity) {
    throw new Error("Insufficient stock")
  }

  let total = item.price * quantity

  if (customer.membershipTier === "gold") {
    total = total * 0.9
  }

  db.execute(
    "INSERT INTO orders (customer_id, item_id, quantity, total) VALUES (?, ?, ?, ?)",
    customerId, itemId, quantity, total
  )

  emailService.send(customer.email, "Order confirmed", `Your total is ${total}`)

  return { success: true, total }
}

At a glance this looks fine. It does one job. It places an order. Clean enough, right?

Look closer at what it's actually doing. It queries a database, runs business rules, writes back to the database, and fires an email - all in one function, all tangled together with no boundary between them.

Now ask yourself some uncomfortable questions.

How do you test just the discount logic? You can't - not without a running database and a live email service. How do you swap PostgreSQL for something else? You have to grep through every function like this one. How do you reproduce a stock-out bug in a test? You need to seed a database first.

Every single business rule is held hostage by its infrastructure.

This is the mess. And most of us are writing it every day without realising it.

Image1

What is the Domain?

Before we fix anything, we need to be precise about what we're actually protecting.

Your domain is the core of your application. The rules. The decisions. The logic your business actually cares about.

In an order management system, the domain looks like this

  • An order can only be placed if the item is in stock
  • A gold member gets a 10% discount
  • An order cannot be cancelled after it has shipped

None of those rules care about databases. None care about HTTP. None care whether you're on AWS or running locally on your laptop. They're just decisions. They're just pure logic.

That's your domain. No infrastructure. No I/O. Just the business thinking.

The moment your business rule reaches out and calls a database or fires an HTTP request, it has left the domain and contaminated itself with infrastructure concerns. And once that happens, your logic is no longer portable, testable, or safe to change in isolation.

Image2

What is I/O?

I/O is everything that talks to the outside world.

  • Reading from or writing to a database
  • Making an HTTP call to a third-party API
  • Publishing a message to a queue
  • Reading from a file
  • Sending an email
  • Getting the current time

That last one catches people off guard. Yes even new Date() is I/O.

Time comes from the system clock, which lives outside your application. Call it inside your business logic and you've introduced non-determinism. Your rule now behaves differently depending on when it runs, not just what it receives. Try writing a reliable test for that.

The key insight: I/O is unpredictable, slow, and external. Your domain should be none of those things.

When your domain calls a database, it is making a bet that the database is up, the schema hasn't changed, and the query returns what you expect. That's three bets on infrastructure, inside your business logic. Every one of those bets can lose independently of whether your business rule is correct.

Keep them separate. Always.

Image3

The Boundary: Ports and Adapters Explained

The fix is a boundary. A hard one.

Not the same as the GoF Adapter Pattern. The Adapter Pattern fixes an interface mismatch between two existing things.

Ports and Adapters is an architectural philosophy - the port exists not because two things are incompatible, but because your domain should never know infrastructure exists at all.

Same word, completely different intent.

Ports are interfaces your domain defines. They describe what the domain needs from the outside world, written entirely in domain terms. Not query the database but find an item by ID. Not call the SendGrid API - but notify the customer. The domain dictates the contract. Infrastructure has no say.

Adapters are the actual implementations. They live outside the domain. They know how to talk to Postgres, DynamoDB, SendGrid, RabbitMQ - whatever you're running. They implement the port contract and plug in from the outside.

The domain never imports an adapter. It only ever calls a port.

// PORTS: defined by the domain, in domain language
interface ItemRepository {
  findById(itemId: string): Item | null
  save(order: Order): void
}

interface CustomerNotifier {
  notify(customerId: string, message: string): void
}
// ADAPTERS: live outside the domain, implement the port
class PostgresItemRepository implements ItemRepository {
  findById(itemId: string): Item | null {
    return db.query("SELECT * FROM items WHERE id = ?", itemId)
  }

  save(order: Order): void {
    db.execute("INSERT INTO orders ...", order)
  }
}

class SendGridNotifier implements CustomerNotifier {
  notify(customerId: string, message: string): void {
    emailService.send(customerId, message)
  }
}

Your domain calls the port. Your adapter implements the port. The domain doesn't care which adapter is plugged in - Postgres, MongoDB, an in-memory fake for tests. It genuinely doesn't matter.

This is the entire idea. The domain defines what it needs. The outside world figures out how to deliver it.

Image4

Before vs After

Same feature. Same business rules. Two completely different worlds.

Before: Everything tangled

function placeOrder(customerId, itemId, quantity) {

  // I/O mixed with logic
  const item = db.query("SELECT * FROM items WHERE id = ?", itemId)
  const customer = db.query("SELECT * FROM customers WHERE id = ?", customerId)

  if (item.stock < quantity) {
    throw new Error("Insufficient stock")
  }

  let total = item.price * quantity

  if (customer.membershipTier === "gold") {
    total = total * 0.9
  }

  // More I/O
  db.execute("INSERT INTO orders ...", customerId, itemId, quantity, total)
  emailService.send(customer.email, "Order confirmed", `Your total is ${total}`)

  return { success: true, total }
}

To test this you need a database, a seeded schema, and a running email service. Change the discount rule and you risk breaking the DB query by accident. Swap your email provider and you're rewriting business logic.

After: Domain isolated behind ports

// DOMAIN: pure logic, receives ports as dependencies
function placeOrder(customer, item, quantity, orderRepo, notifier) {

  if (item.stock < quantity) {
    throw new Error("Insufficient stock")
  }

  let total = item.price * quantity

  if (customer.membershipTier === "gold") {
    total = total * 0.9
  }

  const order = createOrder(customer.id, item.id, quantity, total)

  // calls a PORT — no idea what's behind it
  orderRepo.save(order)

  // calls a PORT — no idea what's behind it
  notifier.notify(customer.id, `Your order total is ${total}`)

  return order
}

Now test it with zero infrastructure:

// Fake adapters for tests - implement the same ports
class InMemoryOrderRepository implements ItemRepository {
  private orders: Order[] = []

  save(order: Order): void {
    this.orders.push(order)
  }
}

class FakeNotifier implements CustomerNotifier {
  public sent: string[] = []

  notify(customerId: string, message: string): void {
    this.sent.push(message)
  }
}

// Test the business rule - no DB, no email, no HTTP
const repo = new InMemoryOrderRepository()
const notifier = new FakeNotifier()

const order = placeOrder(goldCustomer, item, 2, repo, notifier)

assert(order.total === item.price * 2 * 0.9)   // discount applied
assert(notifier.sent.length === 1)               // notification sent

No database. No email service. No HTTP calls. The business rule is tested on its own terms.

Swap the real adapter for another - say MongoDB instead of Postgres - and the domain function doesn't change by a single character. Only the adapter changes.

Image5

Three Rules to Live By

You can read every book on hexagonal architecture and still get it wrong in practice. These three rules keep you honest.

Rule 1: Adapters are dumb. Business logic is not their job.

The moment you put a business decision inside an adapter, you've broken the boundary. Adapters translate. They don't decide.

// WRONG: business rule hiding inside an adapter
class PostgresItemRepository implements ItemRepository {
  findById(itemId: string): Item | null {
    const item = db.query("SELECT * FROM items WHERE id = ?", itemId)

    // This condition does NOT belong here
    if (item.memberOnly && !currentUser.isMember) {
      return null
    }

    return item
  }
}

That membership check is a business rule. It belongs in the domain, tested in isolation, not buried in a database adapter where no one will think to look for it.

Rule 2: Your domain objects should not carry database concerns.

If your Order class has ORM annotations like @Column or @Table baked in, your domain object is already coupled to your database schema. Now a schema migration can break business logic.

// WRONG: domain object carrying DB concerns
class Order {
  @PrimaryGeneratedColumn()
  id: string

  @Column()
  customerId: string
}

// RIGHT: domain object is plain
class Order {
  id: string
  customerId: string
  total: number
  status: OrderStatus
}

Map your domain objects to database models at the adapter layer. Keep the domain object clean.

Rule 3: Test the domain first. Test adapters separately.

A test suite that only hits a real test database is testing infrastructure, not business logic. When a rule breaks, the failure should point to the domain - not a SQL query two layers away.

Write unit tests for the domain using fake adapters. Write separate integration tests for the adapters against real infrastructure. Keep them apart and you always know which layer failed.

Image6

Quick Recap

  • Most backend code mixes business logic and I/O inside the same function - and it silently makes everything harder
  • Your domain is pure business logic: rules, decisions, nothing else
  • I/O is everything external - databases, APIs, queues, even the system clock
  • Ports are interfaces the domain defines, in its own language, describing what it needs
  • Adapters are implementations that live outside the domain and plug into those ports
  • The domain never imports an adapter - it only calls ports
  • Fake adapters in tests mean you test business logic with zero infrastructure
  • Keep adapters dumb, keep domain objects clean, and test the domain first

In the next post, we'll go deeper into Clean Architecture - how Ports and Adapters fits into a broader layered structure and how to organize your entire codebase around this boundary.

Code, learn, refactor, repeat

Reference

  • Hexagonal Architecture - by Alistair Cockburn
  • Ports and Adapters - by Martin Fowler

Comments

Add a new comment
Supports markdown
PreviousDesign Patterns ExplainedAdapter Pattern in TypeScript: Real-World Example and Use Cases

Enjoyed this one?

Subscribe to get posts like this straight to your inbox - no noise, just quality content.

We care about your data. Read our privacy policy.

Stay Connected

GitHub •LinkedIn •X •Daily.dev •Email

© 2026 Chiristo. Feel free to share or reference this post with proper credit