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/
│   ├── contracts/
│   │   └── schemas/          # Shared Zod/Valibot schemas and inferred types
│   ├── domain/
│   │   ├── models/           # ORM models
│   │   ├── events/           # Module events
│   │   ├── observers/        # Model observers
│   │   └── policies/         # Gates and policies
│   ├── application/
│   │   ├── actions/          # Single use-case classes
│   │   ├── dto/              # Validated payload objects
│   │   ├── listeners/        # Event listeners
│   │   ├── notifications/    # Notifications
│   │   └── services/         # Business/application services
│   ├── infrastructure/
│   │   └── repositories/     # Data-access repositories
│   └── interface/
│       └── http/
│           ├── controllers/  # HTTP controllers
│           ├── requests/     # FormRequest validation/authorization
│           └── resources/    # API response resources
├── billing/
│   └── ...
└── posts/
    └── ...

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

The CLI follows this layout automatically:

Artifact DDD path
Model src/lib/modules/<module>/domain/models/
Event src/lib/modules/<module>/domain/events/
Observer src/lib/modules/<module>/domain/observers/
Policy/gates src/lib/modules/<module>/domain/policies/
Action src/lib/modules/<module>/application/actions/
DTO src/lib/modules/<module>/application/dto/
Listener src/lib/modules/<module>/application/listeners/
Notification src/lib/modules/<module>/application/notifications/
Service src/lib/modules/<module>/application/services/
Repository src/lib/modules/<module>/infrastructure/repositories/
Controller src/lib/modules/<module>/interface/http/controllers/
FormRequest src/lib/modules/<module>/interface/http/requests/
Resource src/lib/modules/<module>/interface/http/resources/
Contract schema src/lib/modules/<module>/contracts/schemas/

For a full resource scaffold, use make:entity:

npx svelar make:entity Invoice --module=billing --fields "title:string,total:number,status:enum(draft,paid)" --crud

This creates the model, contract schema, DTOs, requests, actions, resource, repository, service, controller, and a focused table migration using the layered module layout.

Use $lib/... aliases for app-owned imports across this structure:

import { Invoice } from '$lib/modules/billing/domain/models/Invoice.js';
import { BillingAccessService } from '$lib/modules/billing/application/services/BillingAccessService.js';
import { EventServiceProvider } from '$lib/shared/providers/EventServiceProvider.js';

Keep relative imports for local SvelteKit conventions such as ./$types, same-component helpers, stylesheets, or files outside src/lib such as src/app.ts.

Module Boundaries

The most important architectural principle in Svelar is that modules must not reach into each other's internals:

auth/ ──✖──► billing/domain/models/Invoice       # do not reach into internals
auth/ ──✖──► billing/infrastructure/repositories # do not bypass the owner module
auth/ ──✔──► billing/application/services         # allowed public application API
auth/ ──✔──► Event system                         # side-effect communication

Controllers should also never be imported by another module. Controllers are HTTP adapters; reusable behavior belongs in an action, service, query, or facade.

Cross-Module Reads vs Side Effects

Use different communication tools depending on whether the caller needs data back.

Need Use Returns data?
"Something happened; other modules may react" Event + listener No
"Run slow/retryable work after something happened" Event listener dispatches a queue job No
"I need data owned by another module now" Public application service/query/facade Yes

Events are not request/response APIs. Event.dispatch() returns Promise<void>, and listeners should not be used to answer queries.

Events are synchronous unless a listener explicitly hands work to a queue. Keep direct listeners small for immediate side effects, and dispatch queue jobs from listeners for slow, retryable, or external work.

For cross-module reads, create a narrow public service/query/facade in the owning module's application/services folder and return plain data from that module's contracts layer. Do not return the owning module's ORM models to another module.

Example: boards needs billing access information before creating a board.

// src/lib/modules/billing/contracts/billing-access.ts
export type BillingAccess = {
  userId: number;
  plan: 'free' | 'pro' | 'team';
  canCreateBoards: boolean;
  maxBoards: number | null;
};
// src/lib/modules/billing/application/services/BillingAccessService.ts
import type { BillingAccess } from '$lib/modules/billing/contracts/billing-access.js';
import { Subscription } from '$lib/modules/billing/domain/models/Subscription.js';

export class BillingAccessService {
  async forUser(userId: number): Promise<BillingAccess> {
    const subscription = await Subscription.query()
      .where('user_id', userId)
      .first();

    const plan = subscription?.plan ?? 'free';

    return {
      userId,
      plan,
      canCreateBoards: plan !== 'free',
      maxBoards: plan === 'team' ? null : 10,
    };
  }
}
// src/lib/modules/boards/application/services/BoardService.ts
import { BillingAccessService } from '$lib/modules/billing/application/services/BillingAccessService.js';

const billingAccess = new BillingAccessService();

export class BoardService {
  async createBoard(dto: CreateBoardDto, user: UserLike) {
    const access = await billingAccess.forUser(user.id);

    if (!access.canCreateBoards) {
      throw new Error('Your current plan cannot create boards.');
    }

    // Continue with board creation in the boards module...
  }
}

This keeps billing responsible for billing data and rules, while boards depends only on a small public application API and a plain contract type.

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/application/services/AuthService.ts
import { Service } from '@beeblock/svelar/services';
import { Event } from '@beeblock/svelar/events';
import { User } from '$lib/modules/auth/domain/models/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 the module that owns the domain fact. Cross-cutting listeners subscribe through EventServiceProvider instead of importing other modules directly.

// src/lib/modules/auth/domain/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/modules/billing/application/listeners/CreateFreePlan.ts
import { Listener } from '@beeblock/svelar/events';
import type { UserRegistered } from '$lib/modules/auth/domain/events/UserRegistered.js';
import { Invoice } from '$lib/modules/billing/domain/models/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 '$lib/modules/auth/domain/events/UserRegistered.js';
import { CreateFreePlan } from '$lib/modules/billing/application/listeners/CreateFreePlan.js';
import { SendWelcomeEmail } from '$lib/modules/auth/application/listeners/SendWelcomeEmail.js';
import { SyncToAnalytics } from '$lib/modules/analytics/application/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/domain/models/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 directly performs a billing side effect
import { BillingService } from '$lib/modules/billing/application/services/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