The Tickets module is a small, clean reference of a domain with a real state machine. Tickets transition Open → InProgress → Resolved → Closed, comments are immutable once posted, and the aggregate refuses every illegal transition with a Conflict response. It’s not a SaaS-helpdesk product (no SLAs, no escalation routing, no email-to-ticket gateway), it’s the state-machine plumbing a support module needs before any of that layer goes on top.
What ships in v10
- Tickets with auto-generated
Number, title, optional description, status, priority (Low / Medium / High / Critical), reporter user, and optional assignee. - Four-state status machine: Open → InProgress → Resolved → Closed, with reopen-from-Resolved-or-Closed support. Every transition raises a
TicketStatusChangedDomainEvent. - Assignment workflow — assigning when Open moves to InProgress; unassigning from InProgress moves back to Open; assigning Closed is rejected with 409.
- Immutable comments owned by tickets. Closed tickets refuse new comments.
- Domain events for cross-module reactions:
TicketCreatedDomainEvent,TicketAssignedDomainEvent,TicketStatusChangedDomainEvent,TicketCommentAddedDomainEvent. - Soft delete + restore on tickets and comments via
ISoftDeletable. - Search by status, priority, assignee, and free text.
- 9 fine-grained permissions — View / Create / Update / Delete / Restore / Assign / Resolve / Reopen / Comment.
Architecture at a glance
src/Modules/Tickets/├── Modules.Tickets/│ ├── TicketsModule.cs IModule entry — order 700│ ├── Domain/│ │ ├── Ticket.cs AggregateRoot, state machine, ISoftDeletable│ │ └── TicketComment.cs Owned by Ticket, ISoftDeletable│ ├── Data/│ │ ├── TicketsDbContext.cs Tenant-aware│ │ ├── Configurations/ EF type configs│ │ └── TicketsDbInitializer.cs Seeds demo data│ ├── Features/v1/Tickets/│ │ ├── CreateTicket/│ │ ├── AssignTicket/│ │ ├── ResolveTicket/│ │ ├── ReopenTicket/│ │ ├── AddTicketComment/│ │ ├── DeleteTicket/│ │ ├── RestoreTicket/│ │ ├── GetTicketById/│ │ ├── ListTicketComments/│ │ ├── SearchTickets/│ │ └── ListTrashedTickets/│ └── Events/ Domain event handlers└── Modules.Tickets.Contracts/ Public commands/queries/eventsThe module loads at order 700, after Catalog (600). Tenant-aware: each tenant has its own ticket queue.
The state machine
public sealed class Ticket : AggregateRoot, ISoftDeletable{ public TicketStatus Status { get; private set; } public Guid? AssignedToUserId { get; private set; }
public static Ticket Create( long number, string title, string? description, TicketPriority priority, Guid reporterUserId, Guid? assignedToUserId = null) { var ticket = new Ticket { Number = number, Title = title.Trim(), // ... Status = assignedToUserId.HasValue ? TicketStatus.InProgress : TicketStatus.Open, AssignedToUserId = assignedToUserId, CreatedAtUtc = DateTime.UtcNow, }; ticket.RaiseDomainEvent(new TicketCreatedDomainEvent(ticket.Id, ticket.Number)); return ticket; }
public void Assign(Guid? assigneeUserId) { if (Status == TicketStatus.Closed) throw new CustomException("Cannot assign a closed ticket.", HttpStatusCode.Conflict);
AssignedToUserId = assigneeUserId;
if (assigneeUserId.HasValue && Status == TicketStatus.Open) Status = TicketStatus.InProgress; else if (!assigneeUserId.HasValue && Status == TicketStatus.InProgress) Status = TicketStatus.Open;
RaiseDomainEvent(new TicketAssignedDomainEvent(Id, assigneeUserId)); }
public void Resolve(string? resolutionNote) { if (Status == TicketStatus.Closed) throw new CustomException("Cannot resolve a closed ticket.", HttpStatusCode.Conflict); Status = TicketStatus.Resolved; ResolutionNote = resolutionNote; ResolvedAtUtc = DateTime.UtcNow; RaiseDomainEvent(new TicketStatusChangedDomainEvent(Id, TicketStatus.Resolved)); }
public Guid AddComment(Guid authorUserId, string body) { if (Status == TicketStatus.Closed) throw new CustomException("Cannot comment on a closed ticket.", HttpStatusCode.Conflict); var comment = TicketComment.Create(Id, authorUserId, body); _comments.Add(comment); RaiseDomainEvent(new TicketCommentAddedDomainEvent(Id, comment.Id)); return comment.Id; }}Every method that mutates state does three things consistently: validate the source state, mutate, raise the right domain event. That’s the pattern to follow when you add new transitions.
Public API
Ten commands and queries in the Contracts assembly:
| Type | Purpose |
|---|---|
CreateTicketCommand(title, description?, priority, assignedToUserId?) | File a ticket; auto-numbered |
AssignTicketCommand(ticketId, assigneeUserId?) | Assign or unassign (null = unassign) |
ResolveTicketCommand(ticketId, resolutionNote?) | Mark resolved |
ReopenTicketCommand(ticketId) | Reopen a resolved/closed ticket |
AddTicketCommentCommand(ticketId, body) | Post a comment |
RestoreTicketCommand(ticketId) | Restore a soft-deleted ticket |
GetTicketByIdQuery(ticketId) | Fetch single ticket with comments |
ListTicketCommentsQuery(ticketId, skip, take) | Paginated comments |
SearchTicketsQuery(search?, statusFilter?, priorityFilter?, assignedToUserId?, skip, take) | Advanced search |
ListTrashedTicketsQuery(skip, take) | View soft-deleted tickets |
Each command returns either Unit (mutations) or a typed response (TicketResponse, TicketCommentResponse). All are sealed records.
Endpoints
All under /api/v1/tickets, gated by the corresponding Tickets.* permission.
| Verb | Route | Endpoint class |
|---|---|---|
| POST | / | CreateTicketEndpoint |
| GET | /{ticketId} | GetTicketByIdEndpoint |
| GET | /search | SearchTicketsEndpoint |
| GET | /trash | ListTrashedTicketsEndpoint |
| POST | /{ticketId}/assign | AssignTicketEndpoint |
| POST | /{ticketId}/resolve | ResolveTicketEndpoint |
| POST | /{ticketId}/reopen | ReopenTicketEndpoint |
| POST | /{ticketId}/restore | RestoreTicketEndpoint |
| GET | /{ticketId}/comments | ListTicketCommentsEndpoint |
| POST | /{ticketId}/comments | AddTicketCommentEndpoint |
Domain events
Four events ship with the module:
TicketCreatedDomainEvent(Guid TicketId, long Number)— raised byTicket.Create.TicketAssignedDomainEvent(Guid TicketId, Guid? AssigneeUserId)— raised byTicket.Assign.TicketStatusChangedDomainEvent(Guid TicketId, TicketStatus NewStatus)— raised byResolveandReopen.TicketCommentAddedDomainEvent(Guid TicketId, Guid CommentId)— raised byTicket.AddComment.
These are in-module domain events. To react from another module (e.g. send an email when a ticket is resolved), wrap them in an integration event and publish via IEventBus.
Configuration
TicketsDbContext— schematickets, tenant-aware, two tables (Tickets,TicketComments).- Connection — per-tenant via Finbuckle resolution.
- Demo seed —
TicketsDbInitializercreates a handful of tickets whenFSH.Starter.DbMigrator seed-demoruns. - Number generation — handled in
CreateTicketCommandHandler. Read the handler for the exact strategy.
How to extend
Add Close as a public command
Today there’s no CloseTicketCommand — the Closed state is reached via business logic (e.g. an auto-close timer or an admin sweep). Add it explicitly:
public sealed record CloseTicketCommand(Guid TicketId, string? Reason) : ICommand<Unit>;In Ticket.cs:
public void Close(string? reason){ if (Status != TicketStatus.Resolved) throw new CustomException("Can only close a resolved ticket.", HttpStatusCode.Conflict); Status = TicketStatus.Closed; ClosedAtUtc = DateTime.UtcNow; RaiseDomainEvent(new TicketStatusChangedDomainEvent(Id, TicketStatus.Closed));}Add the handler, validator, endpoint, and permission constant — follow the existing Resolve shape exactly.
Email notifications on assignment
Subscribe to TicketAssignedDomainEvent from a handler in the Notifications module:
public sealed class TicketAssignedNotifyHandler(INotificationService notify, IUserService users) : IDomainEventHandler<TicketAssignedDomainEvent>{ public async ValueTask Handle(TicketAssignedDomainEvent evt, CancellationToken ct) { if (!evt.AssigneeUserId.HasValue) return; await notify.SendAsync(evt.AssigneeUserId.Value, "Ticket assigned to you", $"You've been assigned ticket #{evt.TicketId}", ct).ConfigureAwait(false); }}(Cross-module handlers should depend on contracts, not the runtime module — domain events stay in-module; bridge to an integration event if you cross boundaries.)
Add SLA timers
Resolved tickets carry ResolvedAtUtc. Add an ExpectedResolutionAtUtc field set on Assign or Create, then write a Hangfire recurring job that scans for breached SLAs and raises an integration event. The Billing module’s MonthlyInvoiceJob is a template for the job wiring.
Tests
One integration test file at src/Tests/Integration.Tests/Tests/Tickets/. Coverage is light relative to feature completeness — if you customize the state machine, add unit tests on Ticket directly to cover every transition.
Related
- Notifications module — wire ticket events to real-time push.
- Architecture: vertical slice — the feature-folder pattern.
- Modules overview — the other nine modules.