Architecture & Module Communication

Svelar uses a DDD-inspired modular monolith architecture. This guide covers how modules are structured, how they communicate across boundaries, and patterns to avoid.

Module Structure

Each module in src/lib/modules/ is a self-contained domain:

src/lib/modules/
├── auth/
│   ├── User.ts              # Model
│   ├── UserObserver.ts       # Model observer
│   ├── AuthController.ts     # Controller
│   ├── AuthService.ts        # Service (business logic)
│   ├── UserRepository.ts     # Repository (data access)
│   ├── RegisterUser.ts       # Action (single use-case)
│   ├── StoreUserRequest.ts   # FormRequest DTO (validation)
│   └── UserResource.ts       # API resource (response shaping)
├── billing/
│   ├── Invoice.ts
│   ├── BillingService.ts
│   ├── InvoiceRepository.ts
│   └── CreateInvoice.ts
└── posts/
    ├── Post.ts
    ├── PostObserver.ts
    ├── PostController.ts
    ├── PostService.ts
    └── PostRepository.ts

A module owns its models, services, controllers, repositories, actions, observers, and DTOs. Everything related to one domain lives together.

The Golden Rule: Modules Never Import Each Other

This is the most important architectural principle in Svelar:

auth/ ──✖──► billing/     # NEVER import across modules
auth/ ──✔──► Event system  # Always communicate through events

If AuthService needs to trigger something in BillingService, it must not import BillingService directly. Instead, it fires an event, and the billing module listens for it.

Why?

  • Loose coupling — modules can be developed, tested, and refactored independently
  • Clear boundaries — you can see all cross-module communication by looking at the EventServiceProvider
  • Scalability — if billing later becomes a separate microservice, the event interface stays the same
  • Testability — mock events instead of mocking entire modules

Cross-Module Communication via Events

Step 1: Module A fires an event

// src/lib/modules/auth/AuthService.ts
import { Service } from '@beeblock/svelar/services';
import { Event } from '@beeblock/svelar/events';
import { User } from './User.js';

export class AuthService extends Service {
  async register(data: RegisterDTO) {
    const user = await User.create({
      name: data.name,
      email: data.email,
      password: await Hash.make(data.password),
    });

    // Don't call BillingService here — fire an event instead
    await Event.dispatch(new UserRegistered(user));

    return this.ok(user);
  }
}

Step 2: Define the event

Events live in src/lib/events/ — they are shared contracts, not owned by any module:

// src/lib/events/UserRegistered.ts
import type { Model } from '@beeblock/svelar/orm';

export class UserRegistered {
  constructor(public readonly user: Model) {}
}

Step 3: Module B listens for the event

// src/lib/listeners/CreateFreePlan.ts
import { Listener } from '@beeblock/svelar/events';
import type { UserRegistered } from '../events/UserRegistered.js';
import { Invoice } from '../modules/billing/Invoice.js';

export class CreateFreePlan extends Listener<UserRegistered> {
  async handle(event: UserRegistered) {
    await Invoice.create({
      user_id: event.user.getAttribute('id'),
      plan: 'free',
      amount: 0,
    });
  }
}

Step 4: Wire it up in EventServiceProvider

// src/lib/shared/providers/EventServiceProvider.ts
import { EventServiceProvider as BaseProvider } from '@beeblock/svelar/events';
import { UserRegistered } from '../../events/UserRegistered.js';
import { CreateFreePlan } from '../../listeners/CreateFreePlan.js';
import { SendWelcomeEmail } from '../../listeners/SendWelcomeEmail.js';
import { SyncToAnalytics } from '../../listeners/SyncToAnalytics.js';

export class EventServiceProvider extends BaseProvider {
  protected listen = {
    [UserRegistered.name]: [
      SendWelcomeEmail,    // auth concern
      CreateFreePlan,      // billing concern
      SyncToAnalytics,     // analytics concern
    ],
  };
}

Now you can see all cross-module communication in one place. When a user registers, three independent modules react — and none of them know about each other.

Model Lifecycle Events (Automatic)

Every model automatically dispatches events through the Event system. This is the simplest form of cross-module communication:

// No setup needed — these fire automatically
Event.listen('user.created', async (user) => {
  // billing module reacts to auth module's model
  await createFreePlan(user);
});

Event.listen('invoice.created', async (invoice) => {
  // notifications module reacts to billing module's model
  await notifyUser(invoice);
});

Event names follow the pattern {modelname}.{event} (lowercase):

Model Events
User user.creating, user.created, user.updating, user.updated, user.deleting, user.deleted
Post post.creating, post.created, ...
Invoice invoice.creating, invoice.created, ...

Custom Domain Events

For events that don't map to model lifecycle (e.g. "order shipped", "subscription renewed"), use custom model events or standalone event classes:

Custom Model Events

// src/lib/modules/orders/Order.ts
export class Order extends Model {
  static table = 'orders';
  static events = ['shipped', 'cancelled', 'refunded'];

  async ship(trackingNumber: string) {
    await this.update({ status: 'shipped', tracking_number: trackingNumber });
    await this.fireEvent('shipped');
  }
}

// Listened by another module
Event.listen('order.shipped', async (order) => {
  await sendShipmentNotification(order);
  await updateInventory(order);
});

Standalone Event Classes

For events not tied to a model:

// src/lib/events/PaymentReceived.ts
export class PaymentReceived {
  constructor(
    public readonly userId: number,
    public readonly amount: number,
    public readonly currency: string,
    public readonly invoiceId: number,
  ) {}
}

// Dispatched from billing module
await Event.dispatch(new PaymentReceived(user.id, 99.99, 'USD', invoice.id));

// Listened by other modules
Event.listen(PaymentReceived, async (event) => {
  await unlockPremiumFeatures(event.userId);
  await sendReceipt(event.invoiceId);
});

Shared Contracts

When multiple modules need the same data shape, define shared interfaces in src/lib/shared/:

// src/lib/shared/contracts/HasOwner.ts
export interface HasOwner {
  getUserId(): number;
  getOwnerName(): string;
}

// Both auth and billing modules can implement this
// without importing each other

Use shared contracts for:

  • Interfaces that multiple modules implement
  • DTOs passed through events
  • Value objects used across boundaries

Do not put module-specific code in shared/ — only contracts and infrastructure.

Communication Patterns Summary

Pattern When to Use Example
Model lifecycle events React to CRUD operations across modules user.created → create billing profile
Custom model events Domain-specific state changes order.shipped → notify customer
Event classes Complex payloads, typed contracts PaymentReceived → unlock features
Model observers Multiple lifecycle concerns for one model UserObserver → audit, cache, sync
Shared contracts Same interface implemented by multiple modules HasOwner, Billable

Anti-Patterns to Avoid

1. Direct cross-module imports

// BAD — auth module imports billing module directly
import { BillingService } from '../billing/BillingService.js';

export class AuthService extends Service {
  async register(data: RegisterDTO) {
    const user = await User.create(data);
    await new BillingService().createFreePlan(user); // Tight coupling!
  }
}
// GOOD — fire an event, let billing handle itself
export class AuthService extends Service {
  async register(data: RegisterDTO) {
    const user = await User.create(data);
    await Event.dispatch(new UserRegistered(user)); // Loose coupling
  }
}

2. Circular dependencies

If Module A imports Module B and Module B imports Module A, you have a circular dependency. Events eliminate this entirely — neither module imports the other.

3. Fat events with too much data

// BAD — stuffing the entire model with relations into an event
await Event.dispatch(new UserRegistered(await User.with('posts', 'invoices', 'settings').find(id)));

// GOOD — include only what listeners need
await Event.dispatch(new UserRegistered(user)); // Listeners query what they need

4. Listeners reaching back into the source module

// BAD — billing listener imports and calls auth service
export class CreateFreePlan extends Listener<UserRegistered> {
  async handle(event: UserRegistered) {
    const authService = new AuthService(); // Don't reach back!
    await authService.assignRole(event.user, 'free');
  }
}

// GOOD — stay within your own module's boundaries
export class CreateFreePlan extends Listener<UserRegistered> {
  async handle(event: UserRegistered) {
    await Invoice.create({
      user_id: event.user.getAttribute('id'),
      plan: 'free',
      amount: 0,
    });
  }
}

5. Business logic in the EventServiceProvider

// BAD — logic in the provider
protected listen = {
  [UserRegistered.name]: [
    async (event: any) => {
      const user = event.user;
      await Invoice.create({ user_id: user.id, plan: 'free' });
      await Mailer.sendMailable(new WelcomeEmail(user));
      await analytics.track('signup', { email: user.email });
    },
  ],
};

// GOOD — one listener per concern, logic in the listener class
protected listen = {
  [UserRegistered.name]: [CreateFreePlan, SendWelcomeEmail, SyncToAnalytics],
};

Visualizing Module Communication

A well-structured Svelar app's module communication looks like this:

┌──────────┐     ┌──────────────────┐     ┌──────────┐
│   Auth   │────►│   Event System   │◄────│ Billing  │
│  Module  │     │                  │     │  Module  │
└──────────┘     │ UserRegistered   │     └──────────┘
                 │ PaymentReceived  │
┌──────────┐     │ OrderShipped     │     ┌──────────┐
│  Orders  │────►│ PostPublished    │◄────│  Posts   │
│  Module  │     │                  │     │  Module  │
└──────────┘     └──────────────────┘     └──────────┘


                 ┌──────────────────┐
                 │  Notifications   │
                 │     Module       │
                 └──────────────────┘

Every arrow goes through the event system. No module talks to another directly.

Testing Cross-Module Communication

Events make testing straightforward:

// Test that registering a user fires the event
import { Event } from '@beeblock/svelar/events';

test('registration fires UserRegistered event', async () => {
  const events: any[] = [];
  Event.listen('UserRegistered', (e) => events.push(e));

  await authService.register({ name: 'Jane', email: 'jane@test.com', password: 'secret' });

  expect(events).toHaveLength(1);
  expect(events[0].user.getAttribute('email')).toBe('jane@test.com');
});

// Test a listener in isolation — no need to set up the source module
test('CreateFreePlan creates invoice for new user', async () => {
  const listener = new CreateFreePlan();
  const fakeUser = User.hydrate({ id: 1, name: 'Jane', email: 'jane@test.com' });

  await listener.handle(new UserRegistered(fakeUser));

  const invoice = await Invoice.where('user_id', 1).first();
  expect(invoice.getAttribute('plan')).toBe('free');
});

Pipelines (Chain of Responsibility)

While events are fire-and-forget (fan-out), Pipelines are sequential — data flows through a chain of steps where each step transforms it. Think of it as a conveyor belt: each station does one thing, then passes the item to the next.

import { Pipeline } from '@beeblock/svelar/support';

const processedOrder = await Pipeline.send(order)
  .through([
    ValidateStock,
    ApplyDiscount,
    CalculateTax,
    ChargePayment,
  ])
  .thenReturn();

When to Use Pipelines vs Events

Events Pipelines
Data flow Fan-out (many listeners) Sequential (one after another)
Return value None (fire-and-forget) Transformed data
Order Listeners are independent Order is critical
Halting Can't stop other listeners Any pipe can halt the chain
Use case "Notify the world" "Process this through steps"

Creating Pipe Classes

Each pipe class implements a handle(data, next) method. Call next(data) to pass to the next pipe, or return early to halt:

import type { Pipe } from '@beeblock/svelar/support';

interface OrderData {
  items: { productId: number; quantity: number; price: number }[];
  discount: number;
  tax: number;
  total: number;
  userId: number;
}

class ValidateStock implements Pipe<OrderData> {
  async handle(order: OrderData, next: (order: OrderData) => Promise<OrderData>) {
    for (const item of order.items) {
      const product = await Product.find(item.productId);
      if (product.getAttribute('stock') < item.quantity) {
        throw new Error(`Insufficient stock for product ${item.productId}`);
      }
    }
    return next(order); // Pass to next pipe
  }
}

class ApplyDiscount implements Pipe<OrderData> {
  async handle(order: OrderData, next: (order: OrderData) => Promise<OrderData>) {
    const user = await User.find(order.userId);
    if (user.getAttribute('is_premium')) {
      order.discount = 0.1; // 10% off
    }
    return next(order);
  }
}

class CalculateTax implements Pipe<OrderData> {
  async handle(order: OrderData, next: (order: OrderData) => Promise<OrderData>) {
    const subtotal = order.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
    const afterDiscount = subtotal * (1 - order.discount);
    order.tax = afterDiscount * 0.08; // 8% tax
    order.total = afterDiscount + order.tax;
    return next(order);
  }
}

class ChargePayment implements Pipe<OrderData> {
  async handle(order: OrderData, next: (order: OrderData) => Promise<OrderData>) {
    await PaymentGateway.charge(order.userId, order.total);
    return next(order);
  }
}

Inline Pipes

For simple transformations, use inline functions instead of classes:

const result = await Pipeline.send(userInput)
  .through([
    // Inline pipe — trim whitespace
    async (data, next) => {
      data.name = data.name.trim();
      data.email = data.email.trim().toLowerCase();
      return next(data);
    },
    // Class pipe — validate
    ValidateRegistration,
    // Inline pipe — hash password
    async (data, next) => {
      data.password = await Hash.make(data.password);
      return next(data);
    },
  ])
  .thenReturn();

Adding Pipes Individually

const pipeline = Pipeline.send(data)
  .pipe(ValidateStock)
  .pipe(ApplyDiscount)
  .pipe(CalculateTax)
  .pipe(ChargePayment);

const result = await pipeline.thenReturn();

Destination Callback

Use then() instead of thenReturn() to transform the final result:

const invoice = await Pipeline.send(order)
  .through([ValidateStock, ApplyDiscount, CalculateTax, ChargePayment])
  .then(async (processedOrder) => {
    // Final step — create the invoice from the processed order
    return Invoice.create({
      user_id: processedOrder.userId,
      total: processedOrder.total,
      tax: processedOrder.tax,
    });
  });

Error Handling

Use onCatch() to handle errors gracefully instead of letting them bubble up:

const result = await Pipeline.send(order)
  .through([ValidateStock, ApplyDiscount, CalculateTax, ChargePayment])
  .onCatch(async (error, order) => {
    await Log.error('Order processing failed', { error: error.message, orderId: order.id });
    // Return a fallback or re-throw
    order.status = 'failed';
    order.error = error.message;
    return order;
  })
  .thenReturn();

Real-World Examples

Content publishing pipeline:

const published = await Pipeline.send(post)
  .through([
    SanitizeHtml,       // Remove XSS
    ParseMarkdown,      // Convert markdown to HTML
    ExtractMetadata,    // Pull out title, description, images
    GenerateSlug,       // Create URL-friendly slug
    OptimizeImages,     // Compress embedded images
    UpdateSearchIndex,  // Add to search engine
  ])
  .thenReturn();

User onboarding pipeline:

const user = await Pipeline.send(registrationData)
  .through([
    ValidateUniqueEmail,
    HashPassword,
    CreateUserRecord,
    AssignDefaultRole,
    CreateDefaultWorkspace,
    SendWelcomeEmail,
  ])
  .thenReturn();

Data import pipeline:

const imported = await Pipeline.send(csvRows)
  .through([
    ValidateHeaders,
    NormalizeData,
    DeduplicateRows,
    ValidateBusinessRules,
    InsertInBatches,
    GenerateReport,
  ])
  .thenReturn();

Pipelines vs Events — Using Both Together

Pipelines and events complement each other. Use pipelines for the sequential processing, then fire an event when it's done:

// Pipeline processes the order step by step
const order = await Pipeline.send(orderData)
  .through([ValidateStock, ApplyDiscount, CalculateTax, ChargePayment])
  .thenReturn();

// Event notifies other modules that care
await Event.dispatch(new OrderCompleted(order));
// → billing module creates invoice
// → notifications module emails customer
// → analytics module tracks conversion

Next Steps


Svelar Architecture Guide © 2026

Svelar © 2026 · MIT License