Skip to content
fullstackhero

Reference

Tickets module

A complete support-ticket workflow — auto-numbered tickets, status state machine (Open → InProgress → Resolved → Closed), comments, assignment, priority, edit, and soft delete.

views 0 Last updated

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. Close finalizes a resolved ticket (only ResolvedClosed; any other source state is rejected with 409). 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.
  • EditUpdate revises 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 ISoftDeletableDelete trashes a ticket (its comments survive and return on restore), ListTrashed lists the trash, Restore brings 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/events

The module loads at order 700, after Catalog (600). Tenant-aware: each tenant has its own ticket queue.

The state machine

src/Modules/Tickets/Modules.Tickets/Domain/Ticket.cs
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:

TypePurpose
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.

VerbRouteEndpoint classPermission
POST/CreateTicketEndpointTickets.Create
PUT/{ticketId}UpdateTicketEndpointTickets.Update
DELETE/{ticketId}DeleteTicketEndpointTickets.Delete
GET/{ticketId}GetTicketByIdEndpointTickets.View
GET/searchSearchTicketsEndpointTickets.View
GET/trashListTrashedTicketsEndpointTickets.View
POST/{ticketId}/assignAssignTicketEndpointTickets.Assign
POST/{ticketId}/resolveResolveTicketEndpointTickets.Resolve
POST/{ticketId}/reopenReopenTicketEndpointTickets.Reopen
POST/{ticketId}/closeCloseTicketEndpointTickets.Close
POST/{ticketId}/restoreRestoreTicketEndpointTickets.Restore
GET/{ticketId}/commentsListTicketCommentsEndpointTickets.View
POST/{ticketId}/commentsAddTicketCommentEndpointTickets.Comment

Domain events

Four events ship with the module:

  • TicketCreatedDomainEvent(Guid TicketId, long Number) — raised by Ticket.Create.
  • TicketAssignedDomainEvent(Guid TicketId, Guid? AssigneeUserId) — raised by Ticket.Assign.
  • TicketStatusChangedDomainEvent(Guid TicketId, TicketStatus NewStatus) — raised by Resolve and Reopen.
  • TicketCommentAddedDomainEvent(Guid TicketId, Guid CommentId) — raised by Ticket.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 — schema tickets, tenant-aware, two tables (Tickets, TicketComments).
  • Connection — per-tenant via Finbuckle resolution.
  • Demo seedTicketsDbInitializer creates a handful of tickets when FSH.Starter.DbMigrator seed-demo runs.
  • 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 constant

Follow 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.