Working with events

Events in Lino model facts that already happened and help keep .NET applications decoupled across aggregates, modules, services, and asynchronous integrations. This page explains domain events, integration events, their handlers, shadow entities, and the Transactional Outbox flow used by Lino to publish reliable messages with Clean Architecture, DDD, MediatR, MassTransit, RabbitMQ, Hangfire, and Unit of Work.

Domain Events

A domain event represents a relevant fact that already happened inside the domain model. In Lino, a generated domain event is a sealed record ending with DomainEvent and implements IDomainEvent, which is also a MediatR notification. The aggregate or entity stores the fact in its internal event list through RegisterDomainEvent.

Use a domain event when a business operation changes aggregate state and other parts of the same application need to react without direct dependencies. The event should describe the business fact, not the technical action that will run later.

When to use a domain event

  • When an operation produces a significant domain change and other internal rules must react.
  • To keep aggregates cohesive by avoiding direct calls to use cases, modules, messaging infrastructure, or external services.
  • To start an integration flow through a domain handler that registers an Outbox message.
  • To make side effects explicit, such as UserCreated, VehicleCreated, RolePermissionsChanged, or ProductPriceChanged.

Do not use an event as a disguised command. A command asks something to happen; an event states that something already happened. Prefer past-tense names such as UserCreatedDomainEvent or ProductCreatedDomainEvent.

Creating events with the CLI

lino event new
lino event new --name <EventName> --service <ServiceName> --module <ModuleName> --entity <EntityName>
lino event edit --service <ServiceName> --module <ModuleName> --entity <EntityName>
lino event list --service <ServiceName> --module <ModuleName> --entity <EntityName>

The CLI wizard asks for:

  • Service: service where the event will be created.
  • Module: module where the event will be created, when the service is modular.
  • Entity: entity or aggregate associated with the event.
  • Event type: domain event or integration event.
  • Event name: the name of the occurred fact, usually in past tense.
  • Properties: minimal data the handler needs to react to the fact.

Execution flow

  1. A command handler calls a method on an aggregate or entity.
  2. The entity changes state and registers the domain event internally.
  3. The command handler saves through Unit of Work, preferably with SaveChangesInTransactionAsync when events are involved.
  4. The Unit of Work persists aggregate changes and publishes collected events through MediatR.
  5. Domain Event handlers run internal reactions and may register integration events in the Outbox.

Example

Creating the UserCreated domain event associated with the User entity automatically creates UserCreatedDomainEvent. The name makes it clear that user creation has already completed.

<ProjectName>/
└── src/
    └── Services/
        └── <ServiceName>/
            └── Domain/
                β”œβ”€β”€ <ProjectName>.<ServiceName>.Domain.csproj
                └── Aggregates/
                    └── Users/
                        β”œβ”€β”€ User.cs
                        β”œβ”€β”€ Errors/
                        β”œβ”€β”€ Events/
                        β”‚   └── UserCreatedDomainEvent.cs
                        β”œβ”€β”€ Repositories/
                        └── Resources/

Domain Event Handlers

A Domain Event Handler reacts to a domain event inside the same application boundary. In Lino, domain handlers implement IDomainEventHandler and are executed through MediatR when the Unit of Work publishes events collected from tracked entities.

The handler is the right place for rules that should happen because a domain fact occurred, but should not be embedded inside the aggregate. This keeps the aggregate focused on state and invariants while keeping application reactions explicit and testable.

For example, after a User is created, the application may need to update statistics, write an internal log, notify another aggregate, or prepare an integration event. These actions make sense inside the application and can run synchronously when immediate consistency is required.

Slow or unreliable operations, such as sending emails, calling external APIs, or publishing directly to a broker, should not block the domain transaction. In those cases, a domain handler can map the required data to UserCreatedIntegrationEvent and call IOutbox.RegisterIntegrationEvent. The integration is persisted as an OutboxMessage by the same Unit of Work.

Recommended responsibilities

  • React to an already occurred domain fact without changing the event meaning.
  • Run internal consistency rules, logging, tracing, recursive domain updates, or integration registration.
  • Move slow or unreliable external work to integration events.
  • Use the same transaction when registering Outbox messages or depending on saved aggregate state.

Creating handlers with the CLI

lino event-handler new

The CLI wizard asks for:

  • Service: service where the handler will be created.
  • Module: module where the handler will be created, when applicable.
  • Entity: entity/use case associated with the handler.
  • Event type: domain event or integration event.
  • Event: event to consume, such as UserCreatedDomainEvent.
  • Handler name: name that describes the reaction, such as PublishUserCreated.

Step-by-step

  1. Choose the entity or use case that raises the domain event.
  2. Select Domain Event as the handler type.
  3. Select the existing event, for example UserCreatedDomainEvent.
  4. Name the handler by purpose, such as UserCreated or PublishUserCreated.
  5. Implement the generated Handle method with one clear reaction.

Generated structure example

<ProjectName>/
└── src/
    └── Services/
        └── <ServiceName>/
            └── Application/
                β”œβ”€β”€ <ProjectName>.<ServiceName>.Application.csproj
                └── UseCases/
                    └── Users/
                        β”œβ”€β”€ Commands/
                        β”œβ”€β”€ EventHandlers/
                        β”‚   └── Domain/
                        β”‚       └── UserCreatedDomainEventHandler.cs
                        β”œβ”€β”€ Logging/
                        β”œβ”€β”€ Queries/
                        └── Resources/

Integration Events

An integration event is a message contract used to communicate a business fact to another module, service, or system. In Lino, generated integration events are sealed records ending with IntegrationEvent and implement IIntegrationEvent.

Integration Events differ from domain events because they cross a boundary. A domain event describes what happened inside the domain. An integration event carries only the data another context is allowed and needs to know, so the payload should be intentional and stable, not a full aggregate copy.

Lino publishes integration events reliably through the Transactional Outbox pattern. The application registers the event in IOutbox; the Unit of Work converts the envelope to an OutboxMessage and stores it in the same transaction as the business change; later, a worker reads pending messages and publishes them through IEventBus, implemented with MassTransit and RabbitMQ.

Domain Event vs. integration event

AspectDomain EventIntegration Event
ScopeInside the application/domain boundaryAcross modules, services, or systems
ContractInternal notification through IDomainEventMessage contract through IIntegrationEvent
DeliveryPublished by Unit of Work through MediatRPersisted in Outbox and later published by worker/event bus
PayloadCan carry internal aggregate dataShould carry only fields needed by consumers
FailuresAffect the internal transactional flowMust allow retry, auditing, and reprocessing

Creating integration events

lino event new
lino event new --name <EventName> --service <ServiceName> --module <ModuleName> --entity <EntityName>
lino event list --service <ServiceName> --module <ModuleName> --entity <EntityName>

Choose Integration Event in the wizard. Lino asks which entity owns the event and which properties should be included in the message. Primary keys are included so consumers can correlate or upsert local data.

Reliable publication flow

  1. A use case saves a business change in a transaction.
  2. A domain handler creates an integration event and registers it in IOutbox.
  3. The Unit of Work persists an OutboxMessage with event name, assembly-qualified type, serialized content, retry status, and optional scheduled date.
  4. The Hangfire worker reads available messages in batches and publishes them through MassTransit/RabbitMQ.
  5. The consumer integration handler receives the message and updates its own local model.

Generated structure example

<ProjectName>/
└── src/
    └── Services/
        └── <ServiceName>/
            └── Integration.Events/
                β”œβ”€β”€ <ProjectName>.<ServiceName>.Integration.Events.csproj
                └── Users/
                    └── UserCreatedIntegrationEvent.cs

Integration Event Handlers

An Integration Event Handler consumes an integration event published by the messaging infrastructure. In Lino, generated integration handlers implement IIntegrationEventHandler and are MassTransit consumers, receiving a ConsumeContext with the message and cancellation token.

Use integration handlers when a service or module must update its own state based on a fact that happened elsewhere. The handler should not read another module database directly. It consumes the message contract and performs a local write, often creating or updating a local copy or shadow entity with only the fields that context needs.

For example, Shipment can receive vehicle and driver events from Fleet and store associated local records. The same pattern applies when Catalog consumes TenantCreatedIntegrationEvent and UserCreatedIntegrationEvent to keep minimal local copies needed by its context.

Creating integration handlers

lino event-handler new
lino event-handler new --name <EventHandlerName> --service <ServiceName> --module <ModuleName> --entity <EntityName>
lino event-handler list --service <ServiceName> --module <ModuleName> --entity <EntityName>

The CLI asks where the handler will be created and which integration event it will consume. For events from another module or service, Lino also adds the required reference to the integration events project so the handler can use the message contract.

Step-by-step

  1. Choose the consumer service, module, and entity where the local reaction will be implemented.
  2. Select Integration Event as the handler type.
  3. Choose the producer service, module, and entity that owns the event contract.
  4. Select the event to consume, such as TenantCreatedIntegrationEvent or UserCreatedIntegrationEvent.
  5. Name the handler by intent, such as CreateUserShadowOnUserCreated or SendEmailOnUserCreated.
  6. Implement idempotent logic whenever possible, because messages can be reprocessed after failures.

Operational guidance

  • Keep handlers focused on one reaction. One event can have multiple handlers.
  • Persist changes through the local Unit of Work and repositories of the consuming context.
  • Do not read the producer database directly.
  • Let exceptions surface when processing cannot be completed; the Outbox/worker flow records failures and retry attempts.
  • Use logs and traces generated by Lino to inspect the chain from domain event to integration event and consumer processing.

Generated structure example

<ProjectName>/
└── src/
    └── Services/
        └── <ServiceName>/
            └── Application/
                β”œβ”€β”€ <ProjectName>.<ServiceName>.Application.csproj
                └── UseCases/
                    └── Users/
                        β”œβ”€β”€ Commands/
                        β”œβ”€β”€ EventHandlers/
                        β”‚   └── Integration/
                        β”‚       └── SendEmailOnUserCreatedIntegrationEventHandler.cs
                        β”œβ”€β”€ Logging/
                        β”œβ”€β”€ Queries/
                        └── Resources/

Shadow Entities

A shadow entity is a local, minimal, controlled copy of data whose owner is in another module or service. It exists to reduce coupling when the consumer needs to query, validate, or display reference data without directly accessing the producer database.

Shadow Entities are useful when eventual consistency is acceptable. The producer publishes an integration event, the message is persisted in the Outbox, the worker publishes it to the bus, and the consumer updates its local copy through an integration event handler.

lino shadow-entity new
lino event new
lino event-handler new

The command can also be accessed through the lino shadow new alias. In the interactive flow, Lino asks for the target service or module, the source entity, and which properties should be copied. This avoids manually recreating identifier type, requiredness, string length, and basic references, reducing inconsistencies between source and consumer.

The shadow entity is not a full replica. In the example of a Catalog module consuming data from Tenancy and Security, the catalog can keep only Tenant.Id, Tenant.Slug, User.Id, User.Email, and User.TenantId. The original user entity still contains hash, confirmation, tokens, dates, and other internal fields that do not belong to the catalog context.

When to use

  • When the consumer needs a few fields from an entity owned by another context.
  • When the rule accepts a small delay between the producer change and the consumer update.
  • When reading directly from the producer database would create improper coupling between modules or services.
  • When grids, filters, local validations, or projections need external reference data.

When to avoid

  • When the rule requires instantly updated data and does not accept eventual consistency.
  • When the local copy would become a full replica of the original aggregate.
  • When the consumer starts depending on invariants that belong to the producer.

Recommended flow

  1. Model the shadow entity with only the fields needed by the consumer.
  2. Create or select the integration event published by the producer.
  3. Create an integration event handler in the consumer.
  4. Implement an idempotent upsert using the producer key or a natural identifier.
  5. Log enough information to diagnose retries, duplicate messages, and permanent failures.

Event-Driven Synchronization

The complete flow combines domain, application, Unit of Work, Outbox, worker, bus, and consumer. The goal is to avoid fragile direct calls between modules or services and enable retry, auditing, and reprocessing when publication or consumption fails.

  1. An aggregate changes and registers a domain event.
  2. The Unit of Work saves the change inside a transaction.
  3. A domain handler reacts to the fact and registers an integration event in IOutbox.
  4. The Unit of Work persists business data and OutboxMessage in the same transaction.
  5. A Hangfire worker reads the Outbox and publishes to the bus through MassTransit/RabbitMQ.
  6. The consumer receives the message through an IIntegrationEventHandler.
  7. The consumer updates its local model, shadow entity, projection, email, external integration, or another asynchronous flow.
Domain change -> Domain Event -> Domain Handler -> Integration Event -> Outbox -> Worker -> Event Bus -> Consumer -> Local model

Design rules

  • Put invariants in the domain; use events for reactions, not to hide mandatory rules.
  • Register integration events inside the same transaction as the business change.
  • Keep integration contracts small, versionable, and stable.
  • Implement idempotent consumers, because retries and duplicate messages can happen.
  • Monitor worker failures and messages stuck in the Outbox.

Practical result: the producer does not need to know consumers, consumers do not access the producer database, and communication between contexts remains resilient even when the broker or consumer is temporarily unavailable.

An unhandled error has occurred. Reload πŸ—™