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.
Closefinalizes a resolved ticket (onlyResolved→Closed; any other source state is rejected with 409). Every transition raises aTicketStatusChangedDomainEvent. - Assignment workflow — assigning when Open moves to InProgress; unassigning from InProgress moves back to Open; assigning Closed is rejected with 409.
- Edit —
Updaterevises a ticket’s title, description, and priority; a Closed ticket is frozen and must be reopened first (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—Deletetrashes a ticket (its comments survive and return on restore),ListTrashedlists the trash,Restorebrings it back. - Search by status, priority, assignee, and free text.
- 10 fine-grained permissions — View / Create / Update / Delete / Restore / Assign / Resolve / Reopen / Close / 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/│ │ ├── UpdateTicket/│ │ ├── AssignTicket/│ │ ├── ResolveTicket/│ │ ├── ReopenTicket/│ │ ├── CloseTicket/│ │ ├── 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
Thirteen commands and queries in the Contracts assembly:
| Type | Purpose |
|---|---|
CreateTicketCommand(title, description?, priority, assignedToUserId?) | File a ticket; auto-numbered |
UpdateTicketCommand(ticketId, title, description?, priority) | Edit title, description, and priority (rejected on a Closed ticket) |
AssignTicketCommand(ticketId, assigneeUserId?) | Assign or unassign (null = unassign) |
ResolveTicketCommand(ticketId, resolutionNote?) | Mark resolved |
ReopenTicketCommand(ticketId) | Reopen a resolved/closed ticket |
CloseTicketCommand(ticketId) | Finalize a resolved ticket (Resolved → Closed) |
AddTicketCommentCommand(ticketId, body) | Post a comment |
DeleteTicketCommand(ticketId) | Soft-delete (trash) a ticket |
RestoreTicketCommand(ticketId) | Restore a soft-deleted ticket |
GetTicketByIdQuery(ticketId) | Fetch single ticket with comments |
ListTicketCommentsQuery(ticketId, skip, take) | Paginated comments (404 if the ticket doesn’t exist) |
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 | Permission |
|---|---|---|---|
| POST | / | CreateTicketEndpoint | Tickets.Create |
| PUT | /{ticketId} | UpdateTicketEndpoint | Tickets.Update |
| DELETE | /{ticketId} | DeleteTicketEndpoint | Tickets.Delete |
| GET | /{ticketId} | GetTicketByIdEndpoint | Tickets.View |
| GET | /search | SearchTicketsEndpoint | Tickets.View |
| GET | /trash | ListTrashedTicketsEndpoint | Tickets.View |
| POST | /{ticketId}/assign | AssignTicketEndpoint | Tickets.Assign |
| POST | /{ticketId}/resolve | ResolveTicketEndpoint | Tickets.Resolve |
| POST | /{ticketId}/reopen | ReopenTicketEndpoint | Tickets.Reopen |
| POST | /{ticketId}/close | CloseTicketEndpoint | Tickets.Close |
| POST | /{ticketId}/restore | RestoreTicketEndpoint | Tickets.Restore |
| GET | /{ticketId}/comments | ListTicketCommentsEndpoint | Tickets.View |
| POST | /{ticketId}/comments | AddTicketCommentEndpoint | Tickets.Comment |
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 a new state transition
Close (Resolved → Closed) ships as a first-class command, endpoint, and Tickets.Close permission — Ticket.Close() validates the source state, stamps ClosedAtUtc, and raises TicketStatusChangedDomainEvent. Use it as the template for any new transition you need (e.g. an Escalate step):
// 1. Domain — guard the source state, mutate, raise the event (Modules.Tickets/Domain/Ticket.cs)public void Escalate(){ if (Status is TicketStatus.Closed) throw new CustomException("Cannot escalate a closed ticket.", (IEnumerable<string>?)null, HttpStatusCode.Conflict); Priority = TicketPriority.Critical; UpdatedAtUtc = DateTime.UtcNow;}
// 2. Contracts — EscalateTicketCommand(Guid TicketId) : ICommand<Guid>// 3. Feature folder — handler + {Command}Validator + endpoint with .RequirePermission(...)// 4. Register the endpoint in TicketsModule.MapEndpoints and add the permission constantFollow the existing CloseTicket slice end-to-end — every command handler needs a {Command}Validator (enforced by Architecture.Tests).
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
Integration tests live at src/Tests/Integration.Tests/Tests/Tickets/ and cover the lifecycle (create, search/filter, assign, resolve/reopen guards), the close/update/delete operations, the delete → restore round-trip (comments survive), and tenant isolation. 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.