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, 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. 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/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

Ten commands and queries in the Contracts assembly:

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

VerbRouteEndpoint class
POST/CreateTicketEndpoint
GET/{ticketId}GetTicketByIdEndpoint
GET/searchSearchTicketsEndpoint
GET/trashListTrashedTicketsEndpoint
POST/{ticketId}/assignAssignTicketEndpoint
POST/{ticketId}/resolveResolveTicketEndpoint
POST/{ticketId}/reopenReopenTicketEndpoint
POST/{ticketId}/restoreRestoreTicketEndpoint
GET/{ticketId}/commentsListTicketCommentsEndpoint
POST/{ticketId}/commentsAddTicketCommentEndpoint

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 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:

Modules.Tickets.Contracts/v1/Tickets/CloseTicket/CloseTicketCommand.cs
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.