· frameworks  · 8 min read

Beyond MVC: Innovative Design Patterns in AdonisJS

Move past conventional controllers and models. Learn how to apply Hexagonal Architecture, DDD, CQRS, Event Sourcing and related patterns in AdonisJS to build more scalable, testable and resilient applications.

Move past conventional controllers and models. Learn how to apply Hexagonal Architecture, DDD, CQRS, Event Sourcing and related patterns in AdonisJS to build more scalable, testable and resilient applications.

What you’ll be able to build

Read this and you’ll know when to keep MVC - and when to reach for patterns that solve scaling, concurrency, and domain complexity problems in AdonisJS apps. You’ll get concrete implementation sketches (TypeScript), practical pitfalls, and a step-by-step path to prototype Event Sourcing + CQRS or a Hexagonal architecture in an AdonisJS project.

Why start here? Because once your domain is more than simple CRUD, sticking to plain MVC becomes the bottleneck. This article shows alternatives that maintain clarity and improve observability, testability, and scalability.


Why move beyond MVC?

MVC is simple and fast to start with. Short feedback loops. Great for CRUD apps. But as requirements grow, several problems surface:

  • Tight coupling between controllers and persistence logic. Tests become slow and brittle.
  • Hard to reason about past behavior or to rebuild state when bugs happen.
  • Read performance can be constrained by transactional write patterns.
  • Scaling writes and reads independently is difficult.

If any of these sound familiar, patterns like Hexagonal Architecture, Domain-Driven Design (DDD), Command Query Responsibility Segregation (CQRS), and Event Sourcing can help.


Patterns we’ll cover (high level)

  • Hexagonal / Ports & Adapters: decouple domain from framework
  • Domain-Driven Design (strategic & tactical patterns): aggregate roots, value objects
  • CQRS: separate command (write) concerns from query (read) concerns
  • Event Sourcing: persist domain events instead of the current state
  • Saga / Orchestration: manage long-running transactions across services
  • Event-driven microservices and message-driven workflows

Each has trade-offs. Read the short guidance sections below to pick the right tool for your problem.


Hexagonal Architecture in AdonisJS (Ports & Adapters)

Outcome: isolate your domain logic from Adonis framework APIs so you can test business rules in memory and swap persistence / transport layers without affecting domain code.

Concept: put business logic in plain TypeScript classes (the “domain”). Surround it with adapters implementing small interfaces (ports). Controllers become thin: they translate HTTP -> command objects and call application services.

Minimal folder layout suggestion:

  • app/
    • Domain/
      • Order/
        • Order.ts (aggregate)
        • OrderRepository.ts (interface)
    • Services/
      • OrderService.ts (application service) // uses repository interface
    • Adapters/
      • Repositories/
        • LucidOrderRepository.ts (implements OrderRepository)
    • Controllers/
      • OrdersController.ts

Controller example (thin):

// app/Controllers/Http/OrdersController.ts
import type { HttpContextContract } from '@ioc:Adonis/Core/HttpContext';
import OrderService from 'App/Services/OrderService';

export default class OrdersController {
  public async create({ request, response }: HttpContextContract) {
    const payload = request.only(['customerId', 'items']);
    const order = await OrderService.createOrder(payload);
    return response.created(order);
  }
}

Service example (domain orchestration):

// app/Services/OrderService.ts
import OrderRepository from 'App/Domain/Order/OrderRepository';
import Order from 'App/Domain/Order/Order';

export default class OrderService {
  public static async createOrder(payload: any) {
    const order = Order.create(payload);
    await OrderRepository.save(order);
    return order.toDTO();
  }
}

Adonis helps by providing IoC bindings for concrete adapters in a start/app.ts provider or via the container.

Benefits: your domain code is pure and easy to test. You can replace Lucid with an event store later without changing domain.


CQRS: separate writes from reads

Outcome: optimize reads and writes independently, tailor data models for queries, and improve read performance by maintaining materialized views.

Idea: Commands mutate state; Queries read state. The write side can be implemented with complex business rules (and optionally event sourcing), while the read side uses projections or denormalized tables for fast queries.

When to use: complex read models, heavy read traffic, or when you need to scale reads and writes independently.

Simple command handler example using AdonisJS IoC:

// app/Commands/CreateOrderCommand.ts
export default class CreateOrderCommand {
  constructor(
    public customerId: string,
    public items: any[]
  ) {}
}

// app/Handlers/CreateOrderHandler.ts
import CreateOrderCommand from 'App/Commands/CreateOrderCommand';
import EventStore from 'App/Services/EventStore';

export default class CreateOrderHandler {
  public async handle(command: CreateOrderCommand) {
    // validation & domain logic
    const orderCreatedEvent = {
      type: 'OrderCreated',
      payload: { customerId: command.customerId, items: command.items },
    };
    await EventStore.append(command.customerId, orderCreatedEvent);
    // publish to a queue for projection
  }
}

Query handlers simply read from read-model tables (Lucid models or raw SQL) and return optimized responses.


Event Sourcing in AdonisJS

Outcome: persist every domain change as a sequence of immutable events. Rebuild state by replaying events. Gain an audit trail and the ability to support time-travel debugging and flexible projections.

Event store schema (simplified migration SQL):

CREATE TABLE events (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  aggregate_id uuid NOT NULL,
  aggregate_type text NOT NULL,
  event_type text NOT NULL,
  payload jsonb NOT NULL,
  version integer NOT NULL,
  created_at timestamptz DEFAULT now()
);
CREATE INDEX ON events (aggregate_id, version);

Basic EventStore service using Adonis Database:

// app/Services/EventStore.ts
import Database from '@ioc:Adonis/Lucid/Database';

export default class EventStore {
  public static async append(
    aggregateId: string,
    event: any,
    expectedVersion?: number
  ) {
    // optimistic concurrency via expectedVersion
    const trx = await Database.transaction();
    try {
      const last = await trx.rawQuery(
        'SELECT version FROM events WHERE aggregate_id = ? ORDER BY version DESC LIMIT 1',
        [aggregateId]
      );
      const currentVersion = last.rows[0]?.version ?? 0;

      if (expectedVersion !== undefined && currentVersion !== expectedVersion) {
        await trx.rollback();
        throw new Error('ConcurrencyError');
      }

      await trx
        .insertQuery()
        .table('events')
        .insert({
          aggregate_id: aggregateId,
          aggregate_type: 'Order',
          event_type: event.type,
          payload: JSON.stringify(event.payload),
          version: currentVersion + 1,
        });

      await trx.commit();
      return true;
    } catch (err) {
      await trx.rollback();
      throw err;
    }
  }

  public static async load(aggregateId: string) {
    const rows = await Database.query()
      .from('events')
      .where('aggregate_id', aggregateId)
      .orderBy('version');
    return rows.map(r => ({
      type: r.event_type,
      payload: r.payload,
      version: r.version,
    }));
  }
}

Reconstitute aggregate state by applying events to a pure domain aggregate class. Optionally snapshot aggregates for performance.

Projection worker: consume new events (from DB poll, LISTEN/NOTIFY, or message queue) and update read-model tables.

Example projection flow using a queue (BullMQ): produce event after append, process in background to update read tables.


CQRS + Event Sourcing: put it together

Write flow:

  1. HTTP controller translates request -> command.
  2. Command handler validates and creates domain events.
  3. Persist events to event store (append with optimistic concurrency).
  4. Publish events to a message bus for projections & integrations.

Read flow:

  1. Queries read from materialized read tables built by projection workers.
  2. Reads are fast and tuned to needs (denormalized if required).

Benefits: audit log, replay, separate scaling, and powerful offline analytics.

Trade-offs: increased complexity, eventual consistency, operational overhead for event stores and projection workers.


Saga pattern: coordinating multi-step workflows

When an operation spans multiple bounded contexts or services (e.g., order -> payment -> shipping), use Sagas to coordinate. Implement Sagas as orchestrators (central coordinator) or choreography (reactive events). In AdonisJS you can implement Sagas as background workers (BullMQ jobs or lightweight ad-hoc orchestrator classes).

Example orchestration sketch:

  • CreateOrder command produces OrderCreated event.
  • Saga listens for OrderCreated and triggers ReserveInventory command (sync call or message).
  • If ReserveInventory fails, Saga triggers Compensation (e.g., cancel order) and emits events.

Design for idempotency and retries.


Practical implementation tips in AdonisJS

  • Use the IoC container to bind interfaces to adapters. Example: bind OrderRepository to LucidOrderRepository in start/app.ts or a provider.
  • Use Lucid models for read models and simple use-cases. For event store, prefer raw Database or a dedicated store to avoid ORM impedance.
  • For background processing, use BullMQ or Redis Streams. There’s an active ecosystem of Adonis providers you can use or run BullMQ separately.
  • Use optimistic concurrency in the event store (version numbers) to avoid lost updates.
  • Snapshots: for aggregates with many events, persist snapshots periodically.
  • Testing: unit test domain objects without Adonis dependencies. Use integration tests for projection workers and end-to-end flows.

Code-binding example (start container binding):

// start/kernel.ts or a provider
import Application from '@ioc:Adonis/Core/Application';
import LucidOrderRepository from 'App/Adapters/Repositories/LucidOrderRepository';
import OrderRepository from 'App/Domain/Order/OrderRepository';

Application.container.singleton('App/Domain/Order/OrderRepository', () => {
  return new LucidOrderRepository();
});

Testing strategies

  • Unit tests: test aggregates and business rules in memory using fake repositories.
  • Event replay tests: store a sequence of events (fixture) and assert aggregate state after replay.
  • Integration tests: test projections by running a real DB and a projection worker in the test harness (or use test doubles for message brokers).
  • Contract tests for Sagas when interacting across services.

Observability and operational concerns

  • Logging: log every event persist and projection step. Include correlation IDs.
  • Monitoring: track projection lag (difference between last written event and last projected event).
  • Schema evolution: version events and write migration handlers to handle old event shapes.
  • Backups: persist events and read models; ensure you can replay events to rebuild read models.

When NOT to use these patterns

  • Small apps or prototypes: complexity outweighs benefits.
  • Very simple CRUD apps with little domain logic or low expected growth.

Start with simpler Hexagonal components (to keep domain isolated) and only adopt Event Sourcing/CQRS when justified.


Migration strategy: from MVC to Event Sourcing / CQRS gradually

  1. Extract business logic into domain services and repositories (Hexagonal architecture).
  2. Add a read-model layer for complex queries - create projection tables behind a Query API.
  3. Start writing events for new features only (strangler pattern). Persist canonical state in your existing models for reads until projections catch up.
  4. Migrate historical data by replaying domain changes into the event store if needed.

Example: small end-to-end sketch (pseudo-code)

  1. Controller -> Command
// POST /orders
await container
  .resolveBinding('App/Handlers/CreateOrderHandler')
  .handle(new CreateOrderCommand(customerId, items));
  1. Handler saves to EventStore and pushes event to queue
await EventStore.append(orderId, orderCreatedEvent, expectedVersion);
Queue.publish('order_events', orderCreatedEvent);
  1. Projection worker consumes queue and updates orders_read table
Queue.process('order_events', async job => {
  const event = job.data;
  if (event.type === 'OrderCreated') {
    await Database.insertQuery()
      .table('orders_read')
      .insert({
        id: event.payload.orderId,
        customer_id: event.payload.customerId,
        total: event.payload.total,
      });
  }
});
  1. Queries read from orders_read (fast, denormalized)

Common pitfalls and how to avoid them

  • Underestimating complexity: prototype before committing to full rewrite.
  • Ignoring idempotency: make event handlers idempotent and safe to retry.
  • Missing backward compatibility: version events and write transformation handlers.
  • Poor observability: add metrics and logging from day one.

Quick decision checklist

  • Do you need full audit/time-travel or complex domain logic? -> Consider Event Sourcing.
  • Do reads need separate optimization or independent scaling? -> Consider CQRS.
  • Want maintainable domain code and easy tests? -> Hexagonal + DDD patterns.
  • Do you need orchestrated multi-step transactions across services? -> Sagas.

Next steps: how to prototype in your AdonisJS app

  1. Extract a representative domain use-case into domain classes and repository interfaces.
  2. Implement a small event store (Postgres table) and a projection worker.
  3. Build one feature with events + projection; make reads from the projection table.
  4. Add monitoring for projection lag and test event replay.

Resources


Designing beyond MVC in AdonisJS is not about discarding MVC entirely. It’s about placing it where it belongs - the transport layer - and giving your domain the right architecture to scale, evolve, and remain testable. Start small, prove the value with a single feature, and iterate.

Back to Blog

Related Posts

View All Posts »
Fastify vs. Express: The Ultimate Efficiency Showdown

Fastify vs. Express: The Ultimate Efficiency Showdown

A practical, opinionated comparison of Fastify and Express focused on performance. Learn how they differ under load, how to benchmark them yourself, and when Fastify's design gives it a real advantage for high-throughput Node.js services.

5 Hidden Features of AdonisJS You Didn't Know Existed

5 Hidden Features of AdonisJS You Didn't Know Existed

Discover five lesser-known AdonisJS features - route param/model binding, named routes & URL generation, Lucid computed/serialization tricks, advanced validator schema patterns, and streaming-safe file uploads - with practical examples and tips to make your apps faster to build and easier to maintain.