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(sequential, tenant-scoped strings:TK-1,TK-2, …), 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 (idempotent when already Closed; any other non-Resolved 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 a Resolved or Closed ticket 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. The tenant dashboard’s Trash page restores tickets from here (permission-gated tab). - 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<Guid>, ISoftDeletable{ public string Number { get; private set; } = default!; // "TK-1", "TK-2", … tenant-scoped public TicketStatus Status { get; private set; } public Guid? AssignedToUserId { get; private set; }
public static Ticket Create( string number, string title, string? description, TicketPriority priority, Guid reporterUserId, Guid? assignedToUserId) { // A ticket assigned at creation jumps straight to InProgress. var initialStatus = assignedToUserId is not null ? TicketStatus.InProgress : TicketStatus.Open;
var ticket = new Ticket { Id = Guid.CreateVersion7(), Number = number, Title = title.Trim(), // ... Status = initialStatus, AssignedToUserId = assignedToUserId, CreatedAtUtc = DateTime.UtcNow, }; ticket.AddDomainEvent(DomainEvent.Create<TicketCreatedDomainEvent>( (id, ts) => new TicketCreatedDomainEvent( ticket.Id, ticket.Number, ticket.Title, ticket.Priority, ticket.ReporterUserId, ticket.AssignedToUserId, id, ts))); return ticket; }
public void Assign(Guid? assigneeUserId) { ThrowIfClosedOrResolved("assign"); // 409 from Resolved or Closed if (assigneeUserId == AssignedToUserId) return; // idempotent
var previous = AssignedToUserId; AssignedToUserId = assigneeUserId;
// Picking up a ticket implicitly starts it; unassigning sends it back to Open. if (assigneeUserId is not null && Status == TicketStatus.Open) TransitionStatus(TicketStatus.InProgress); else if (assigneeUserId is null && Status == TicketStatus.InProgress) TransitionStatus(TicketStatus.Open);
AddDomainEvent(DomainEvent.Create<TicketAssignedDomainEvent>( (id, ts) => new TicketAssignedDomainEvent(Id, previous, assigneeUserId, id, ts))); }
public void Resolve(string? resolutionNote) { if (Status == TicketStatus.Closed) throw new CustomException("A closed ticket cannot be resolved — reopen it first.", (IEnumerable<string>?)null, HttpStatusCode.Conflict); if (Status == TicketStatus.Resolved) return; // idempotent
ResolutionNote = string.IsNullOrWhiteSpace(resolutionNote) ? null : resolutionNote.Trim(); ResolvedAtUtc = DateTime.UtcNow; TransitionStatus(TicketStatus.Resolved); }
public Guid AddComment(Guid authorUserId, string body) { if (Status == TicketStatus.Closed) throw new CustomException("A closed ticket cannot accept new comments — reopen it first.", (IEnumerable<string>?)null, HttpStatusCode.Conflict);
var comment = TicketComment.Create(Id, authorUserId, body); _comments.Add(comment); AddDomainEvent(DomainEvent.Create<TicketCommentAddedDomainEvent>( (id, ts) => new TicketCommentAddedDomainEvent(Id, comment.Id, authorUserId, id, ts))); return comment.Id; }
private void TransitionStatus(TicketStatus next) { if (next == Status) return; var previous = Status; Status = next; AddDomainEvent(DomainEvent.Create<TicketStatusChangedDomainEvent>( (id, ts) => new TicketStatusChangedDomainEvent(Id, previous, next, id, ts))); }}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) | All comments for a ticket (404 if the ticket doesn’t exist) |
SearchTicketsQuery { search?, status?, priority?, assignedToUserId?, reporterUserId?, pageNumber, pageSize, sortBy?, sortDir? } | Advanced search |
ListTrashedTicketsQuery(pageNumber, pageSize) | View soft-deleted tickets |
Most commands return the ticket id (ICommand<Guid>); DeleteTicketCommand returns Unit, queries return TicketDto / TicketCommentDto shapes. 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 | / | SearchTicketsEndpoint (filters via query string) | Tickets.View |
| GET | /trash | ListTrashedTicketsEndpoint | Tickets.Restore |
| 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 (each also carries an EventId and OccurredOnUtc from the DomainEvent base):
TicketCreatedDomainEvent(TicketId, Number, Title, Priority, ReporterUserId, AssignedToUserId)— raised byTicket.Create.TicketAssignedDomainEvent(TicketId, PreviousAssigneeUserId, AssigneeUserId)— raised byTicket.Assign.TicketStatusChangedDomainEvent(TicketId, PreviousStatus, NewStatus)— raised by every status transition, including the implicit Open ↔ InProgress moves that assignment triggers.TicketCommentAddedDomainEvent(TicketId, CommentId, AuthorUserId)— 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: sequential, tenant-scopedTK-{n}strings. The count includes soft-deleted tickets so a trashed number is never reused; racing writers collide on the unique index and get a retryable 409.
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).
Notify the assignee
TicketAssignedDomainEvent is an in-module domain event — it can’t cross the module boundary. Bridge it: handle it in-module with a Mediator INotificationHandler<TicketAssignedDomainEvent>, publish a TicketAssignedIntegrationEvent (declared in Modules.Tickets.Contracts) via IEventBus, then add an IIntegrationEventHandler in the Notifications module that writes the inbox row and pushes NotificationCreated over SignalR. The Notifications module page walks through exactly this example end-to-end.
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.