DEV Community

Cover image for Pagination: Does It Belong in the Domain or in the Adapter?
Gabriel Anhaia
Gabriel Anhaia

Posted on

Pagination: Does It Belong in the Domain or in the Adapter?


You sit down to add pagination to a list-orders endpoint. The domain has a clean OrderRepository port. You open it, and the first question hits before you've typed a character: what's the method signature?

public function listForCustomer(CustomerId $id, int $page, int $perPage): array;
Enter fullscreen mode Exit fullscreen mode

Page and per-page. Simple. Until somebody complains that page 8000 of the admin export takes 14 seconds, and you realize you've baked OFFSET 80000 into the contract every consumer of this port now depends on. Switching to keyset pagination means changing the port. Changing the port means touching every adapter and every use case that calls it.

This is the pagination seam problem in hexagonal PHP. Pagination is half-domain and half-infrastructure, and most codebases get the line wrong. The domain doesn't care that the database can do offset scans; it cares that a list has an order, a size, and a way to ask for "what comes after this." The adapter cares about cursor encoding, index columns, and whether the underlying store can do keyset scans at all.

A Pagination value object lives in the domain. Cursor encoding, offset translation, and SQL belong in the adapter. The contract between them is a Page<T> return type that carries items and a nextCursor.

Domain Pagination value object passing an opaque cursor across the port boundary to a keyset SQL adapter

What the domain actually needs to say

The domain wants three things when it asks for a list:

  1. A size. How many items per response. Capped.
  2. A position. "From the start" or "after the thing the previous page ended on."
  3. An ordering. The business decides the order. Newest first. Highest balance first. Alphabetical. The database does not get to pick.

Notice what's missing. There's no page number, no offset, no cursor string. Those are encodings, and encodings are an adapter concern. The domain says "give me the next 20 orders for this customer, newest first, starting after the one I last saw." The adapter figures out how to express that against PostgreSQL or DynamoDB or an in-memory fake.

Here's the Pagination value object in PHP 8.3, written exactly that way:

<?php

declare(strict_types=1);

namespace App\Domain\Common;

final readonly class Pagination
{
    public function __construct(
        public Limit $limit,
        public ?Cursor $after = null,
    ) {}

    public static function firstPage(int $limit): self
    {
        return new self(new Limit($limit), null);
    }

    public function after(Cursor $cursor): self
    {
        return new self($this->limit, $cursor);
    }
}
Enter fullscreen mode Exit fullscreen mode

Limit is its own type because "an int between 1 and 100" is a domain rule, not a database rule. The cap is there to stop an API caller from asking for 50,000 rows. The floor is there because zero items isn't a page.

<?php

declare(strict_types=1);

namespace App\Domain\Common;

final readonly class Limit
{
    private const int MIN = 1;
    private const int MAX = 100;

    public int $value;

    public function __construct(int $value)
    {
        if ($value < self::MIN || $value > self::MAX) {
            throw new \DomainException(
                "Limit must be between "
                . self::MIN . " and " . self::MAX
                . ", got {$value}"
            );
        }
        $this->value = $value;
    }
}
Enter fullscreen mode Exit fullscreen mode

Cursor is the part most people get wrong. It looks like it should hold an ID, or a timestamp, or a composite key. It shouldn't. From the domain's point of view, a cursor is an opaque token. The domain receives one from the previous page and hands it back to ask for the next. It never inspects the contents.

<?php

declare(strict_types=1);

namespace App\Domain\Common;

final readonly class Cursor
{
    public function __construct(public string $token)
    {
        if ($token === '') {
            throw new \DomainException('Cursor token cannot be empty');
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

That's a 20-line value object that holds a string. The reason it's a class and not a string parameter is type discipline. A Cursor can't accidentally be passed where a customer ID is expected, and the port signature reads like an English sentence. Same payoff you get from any tiny value object: the type checker does work the linter used to.

The port speaks domain, not SQL

With the value objects above, the repository port reads like the domain question it's answering:

<?php

declare(strict_types=1);

namespace App\Domain\Order;

use App\Domain\Common\Pagination;
use App\Domain\Customer\CustomerId;

interface OrderRepository
{
    public function listForCustomer(
        CustomerId $customerId,
        Pagination $pagination,
    ): Page;
}
Enter fullscreen mode Exit fullscreen mode

And Page is a generic-ish carrier — PHP doesn't have proper generics, but the shape is:

<?php

declare(strict_types=1);

namespace App\Domain\Common;

final readonly class Page
{
    /**
     * @param list<object> $items
     */
    public function __construct(
        public array $items,
        public ?Cursor $nextCursor,
    ) {}

    public function hasMore(): bool
    {
        return $this->nextCursor !== null;
    }
}
Enter fullscreen mode Exit fullscreen mode

That's the whole contract. items is what you asked for. nextCursor is null when you've reached the end and a fresh Cursor otherwise. The use case calling this port doesn't know whether the underlying store paginated by keyset, offset, or by hand-rolling row numbers. It hands back the cursor it received and asks for more.

A use case using it looks like this — note how plain it reads:

<?php

declare(strict_types=1);

namespace App\Application\Order;

use App\Domain\Common\Cursor;
use App\Domain\Common\Pagination;
use App\Domain\Customer\CustomerId;
use App\Domain\Order\OrderRepository;

final readonly class ListCustomerOrders
{
    public function __construct(
        private OrderRepository $orders,
    ) {}

    public function __invoke(
        string $customerId,
        int $limit,
        ?string $afterToken,
    ): array {
        $pagination = $afterToken === null
            ? Pagination::firstPage($limit)
            : Pagination::firstPage($limit)
                ->after(new Cursor($afterToken));

        $page = $this->orders->listForCustomer(
            new CustomerId($customerId),
            $pagination,
        );

        return [
            'items' => $page->items,
            'next' => $page->nextCursor?->token,
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

No SQL. No OFFSET. No mention of how the rows come back. The HTTP adapter on top of this is the one that exposes ?after= and ?limit= query parameters; the domain pulls back to its proper job, which is describing what a list is.

The adapter does the SQL — keyset pagination, base64 cursors

Now for the half nobody talks about cleanly: the adapter. The cursor token is opaque to the domain, but it has to mean something concrete to the database. For keyset (also called "seek") pagination, the cursor encodes the position of the last row returned. For an order list sorted by (created_at DESC, id DESC), that's (created_at, id) of the final row.

Encode it as base64 JSON so it stays URL-safe and you can extend the encoded fields later without breaking older clients:

<?php

declare(strict_types=1);

namespace App\Infrastructure\Persistence\Order;

use App\Domain\Common\Cursor;

final readonly class OrderCursorCodec
{
    public function encode(\DateTimeImmutable $createdAt, string $id): Cursor
    {
        $payload = [
            'c' => $createdAt->format(\DateTimeInterface::RFC3339_EXTENDED),
            'i' => $id,
        ];
        $json = json_encode($payload, JSON_THROW_ON_ERROR);
        return new Cursor(
            rtrim(strtr(base64_encode($json), '+/', '-_'), '=')
        );
    }

    /**
     * @return array{0: \DateTimeImmutable, 1: string}
     */
    public function decode(Cursor $cursor): array
    {
        $b64 = strtr($cursor->token, '-_', '+/');
        $b64 .= str_repeat('=', (4 - strlen($b64) % 4) % 4);
        $json = base64_decode($b64, true);
        if ($json === false) {
            throw new \InvalidArgumentException('Malformed cursor');
        }
        $data = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
        return [
            new \DateTimeImmutable($data['c']),
            (string) $data['i'],
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

The codec lives in the infrastructure namespace. The domain never imports it. If a future migration moves orders from PostgreSQL to a different store with different sort columns, the codec changes, and the port doesn't.

The repository itself ties the pieces together:

<?php

declare(strict_types=1);

namespace App\Infrastructure\Persistence\Order;

use App\Domain\Common\Page;
use App\Domain\Common\Pagination;
use App\Domain\Customer\CustomerId;
use App\Domain\Order\Order;
use App\Domain\Order\OrderRepository;
use Doctrine\DBAL\Connection;

final readonly class DoctrineOrderRepository implements OrderRepository
{
    public function __construct(
        private Connection $db,
        private OrderCursorCodec $cursors,
        private OrderHydrator $hydrator,
    ) {}

    public function listForCustomer(
        CustomerId $customerId,
        Pagination $pagination,
    ): Page {
        $sql = 'SELECT id, customer_id, total_cents, created_at
                FROM orders
                WHERE customer_id = :customer_id';
        $params = ['customer_id' => $customerId->value];

        if ($pagination->after !== null) {
            [$afterCreatedAt, $afterId] = $this->cursors->decode(
                $pagination->after
            );
            $sql .= ' AND (created_at, id) < (:after_created, :after_id)';
            $params['after_created'] = $afterCreatedAt->format(
                'Y-m-d H:i:s.uP'
            );
            $params['after_id'] = $afterId;
        }

        $sql .= ' ORDER BY created_at DESC, id DESC LIMIT :limit';
        $params['limit'] = $pagination->limit->value + 1;

        $rows = $this->db->fetchAllAssociative($sql, $params);

        $hasMore = count($rows) > $pagination->limit->value;
        if ($hasMore) {
            array_pop($rows);
        }

        $orders = array_map(
            fn (array $row): Order => $this->hydrator->fromRow($row),
            $rows,
        );

        $next = null;
        if ($hasMore && $rows !== []) {
            $last = end($rows);
            $next = $this->cursors->encode(
                new \DateTimeImmutable($last['created_at']),
                (string) $last['id'],
            );
        }

        return new Page($orders, $next);
    }
}
Enter fullscreen mode Exit fullscreen mode

The LIMIT :limit + 1 is the standard keyset "do I have more?" probe. Fetch one extra row; if you got it, there's a next page and the extra row tells you the cursor for it.

The (created_at, id) < (:after_created, :after_id) row-value comparison is the part that makes keyset pagination work correctly when timestamps collide. PostgreSQL, MySQL 8.0+, and SQLite all support this syntax. If your stack doesn't, fall back to created_at < :a OR (created_at = :a AND id < :b) — same shape, more verbose.

Keyset SQL with row-value comparison and a +1 limit probe, contrasted with the OFFSET 80000 anti-pattern

The in-memory fake — why this layout pays off in tests

Because the port doesn't mention SQL or cursors, the in-memory test double is honest about its own shape:

<?php

declare(strict_types=1);

namespace App\Tests\Doubles;

use App\Domain\Common\Cursor;
use App\Domain\Common\Page;
use App\Domain\Common\Pagination;
use App\Domain\Customer\CustomerId;
use App\Domain\Order\Order;
use App\Domain\Order\OrderRepository;

final class InMemoryOrderRepository implements OrderRepository
{
    /** @var array<string, list<Order>> */
    private array $byCustomer = [];

    public function add(Order $order): void
    {
        $this->byCustomer[$order->customerId->value][] = $order;
    }

    public function listForCustomer(
        CustomerId $customerId,
        Pagination $pagination,
    ): Page {
        $rows = $this->byCustomer[$customerId->value] ?? [];
        usort(
            $rows,
            fn (Order $a, Order $b) => $b->createdAt <=> $a->createdAt,
        );

        $start = 0;
        if ($pagination->after !== null) {
            $start = (int) $pagination->after->token + 1;
        }

        $slice = array_slice($rows, $start, $pagination->limit->value);
        $next = ($start + count($slice)) < count($rows)
            ? new Cursor((string) ($start + count($slice) - 1))
            : null;

        return new Page($slice, $next);
    }
}
Enter fullscreen mode Exit fullscreen mode

The fake's cursor token is just a numeric index. That's fine. It's opaque to the domain, the domain doesn't peek inside, and tests never assert on the string. Two adapters, two encodings, one contract. The use case test doesn't care which one is wired in.

What you keep, what you push out

Keep in the domain:

  • Pagination, Limit, Cursor, Page. Four value objects, all immutable, all readonly.
  • Ordering decisions when they're a business rule (newest first, by amount, by status). When the order is "whatever the database returns," it's not a domain rule and it doesn't belong here either — but that's a separate post.
  • The port signature: it takes a Pagination, it returns a Page. No int $page, no string $cursor, ever.

Push to the adapter:

  • Cursor encoding. Base64 JSON, signed JWT, raw column tuple. Adapter's call.
  • The choice of keyset vs offset vs Mongo _id-greater-than. Different stores, different mechanics, same port.
  • The +1 probe, the row-value comparison, the index hint, the explain plan.

The seam is small and it holds. When somebody complains about page-8000 latency a year from now, you change one adapter file, write a migration to drop the offset code path, and ship. The use case doesn't know it happened. The HTTP contract doesn't break, and the cursor is still a string, still opaque, still works.

That's what hexagonal pays you for: the ability to swap an implementation that's hurting you without rewriting the ten things that depend on it.


If this was useful

Where to draw the line between domain and adapter is the question every chapter of Decoupled PHP circles back to. Pagination is one of the cleaner examples; transactions, error translation, and event delivery are messier and get their own chapters. If you've ever stared at a port signature wondering whether to put SQL-shaped types in it, the book is built around getting that decision right by default.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)