Application Layer (ServiceDeskLite.Application)
Result Pattern
Handlers never throw. All outcomes are expressed via Result or Result<T>.
Result (void success)
public class Result
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public ApplicationError? Error { get; }
public static Result Success()
public static Result Failure(ApplicationError error)
public static Result NotFound(string code, string message,
IReadOnlyDictionary<string, object>? meta = null)
public static Result Validation(string code, string message,
IReadOnlyDictionary<string, object>? meta = null)
public static Result DomainViolation(string code, string message,
IReadOnlyDictionary<string, object>? meta = null)
}
Result<T> (value on success)
public class Result<T>
{
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public T? Value { get; } // Throws InvalidOperationException if IsFailure
public ApplicationError? Error { get; }
public static Result<T> Success(T value)
public static Result<T> Failure(ApplicationError error)
public static Result<T> NotFound(string code, string message,
IReadOnlyDictionary<string, object>? meta = null)
public static Result<T> Validation(string code, string message,
IReadOnlyDictionary<string, object>? meta = null)
public static Result<T> DomainViolation(string code, string message,
IReadOnlyDictionary<string, object>? meta = null)
public static Result<T> Conflict(string code, string message,
IReadOnlyDictionary<string, object>? meta = null)
}
ApplicationError
public sealed record ApplicationError(
string Code,
string Message,
ErrorType Type,
IReadOnlyDictionary<string, object>? Meta = null)
{
public static ApplicationError Validation(string code, string message, ...)
public static ApplicationError NotFound(string code, string message, ...)
public static ApplicationError Conflict(string code, string message, ...)
public static ApplicationError DomainViolation(string code, string message, ...)
public static ApplicationError Unexpected(string code, string message, ...)
}
Result & Error Type Model
ErrorType → HTTP Status Mapping
| ErrorType | HTTP Status |
|---|---|
Validation |
400 |
DomainViolation |
400 |
NotFound |
404 |
Conflict |
409 |
Unexpected |
500 |
Use Case Handlers
Each use case lives in its own folder under Application/Tickets/<UseCase>/. Structure:
<UseCase>Command.csor<UseCase>Query.cs– input record<UseCase>Handler.cs– handler withHandleAsyncmethod<UseCase>Result.csor<UseCase>Dto.cs– output record/DTO
CQRS in This Project (lightweight)
This solution uses lightweight CQRS as an application-structure pattern, not as a distributed systems pattern.
| Topic | Current project scope |
|---|---|
| Command / query split | Commands and queries are modeled as separate request types with dedicated handlers. |
| Write side | State-changing use cases (CreateTicket, ChangeTicketStatus, AssignTicket, AddComment) execute through command handlers. |
| Read side | Read use cases (GetTicketById, SearchTickets, GetAuditEvents, GetDashboardSummary) execute through query handlers and return read-oriented DTOs. |
| Endpoint dispatch | ASP.NET Core Minimal API endpoints inject handlers directly from DI and call HandleAsync(...) explicitly. |
| Persistence model | The separation is logical inside one application and one persistence boundary, not separate write and read databases. |
Deliberately not included in the current scope:
- No
MediatR/ISenderdispatch layer between endpoints and handlers. - No
IPipelineBehavior<,>pipeline for validation, logging, or transactions. - No separate read store, projection daemon, or eventual consistency workflow.
- No asynchronous command bus or out-of-process message dispatch for request handling.
This boundary is intentional. The project is positioned as a reference/showcase, so the current design favors explicit request flow and low ceremony over maximum pattern coverage.
Extension paths if the scope changes later:
- Introduce MediatR when cross-cutting handler concerns need one uniform request pipeline.
- Introduce pipeline behaviors when validation, logging, auth, or transaction policies must apply consistently to every handler call.
- Introduce dedicated read models or a separate read store only when query complexity, performance, or projection needs justify that extra operational cost.
Handler signature contract:
public async Task<Result<TOutput>> HandleAsync(TInput? input, CancellationToken ct = default)
// Null input → return Result<T>.Validation(...), never throw
All handlers follow the same guard-then-act pattern. CreateTicket is the canonical example:
CreateTicket
public sealed record CreateTicketCommand(
string Title,
string Description,
TicketPriority Priority,
DateTimeOffset CreatedAt,
DateTimeOffset? DueAt = null);
public sealed record CreateTicketResult(TicketId Id);
// Handler: validates null (command + field-level), catches DomainException,
// AddAsync + audit record + SaveChangesAsync (atomic).
// Duplicate IDs are not checked via ExistsAsync; uniqueness is enforced
// by the persistence layer and mapped to Result<T>.Conflict by
// PersistenceExceptionMapper.
public sealed class CreateTicketHandler
{
public async Task<Result<CreateTicketResult>> HandleAsync(
CreateTicketCommand? command, CancellationToken ct = default)
}
GetTicketById
public sealed record GetTicketByIdQuery(TicketId Id);
public record TicketDetailsDto(
TicketId Id,
string Title,
string Description,
TicketStatus Status,
TicketPriority Priority,
DateTimeOffset CreatedAt,
DateTimeOffset? DueAt);
public sealed class GetTicketByIdHandler
{
public async Task<Result<TicketDetailsDto>> HandleAsync(
GetTicketByIdQuery? query, CancellationToken ct = default)
}
SearchTickets
public sealed record SearchTicketsQuery(
TicketSearchCriteria Criteria,
Paging Paging,
SortSpec? Sort = null);
public sealed record SearchTickesResult(PagedResult<TicketListItemDto> Page);
public class SearchTicketsHandler
{
public async Task<Result<SearchTickesResult>> HandleAsync(
SearchTicketsQuery? query, CancellationToken ct = default)
}
ChangeTicketStatus
public sealed record ChangeTicketStatusCommand(TicketId Id, TicketStatus NewStatus, string? Actor = null);
// Handler: validates null, loads ticket (NotFound if missing),
// calls ticket.ChangeStatus, records audit event, saves via UnitOfWork.
// invalid_transition → Result<T>.Conflict (409)
// other DomainException → Result<T>.DomainViolation (400)
// No ICommandValidator – all fields are strongly typed (TicketId, TicketStatus).
// Transition logic is domain behaviour, not input validation (see ADR 0019).
public sealed class ChangeTicketStatusHandler
{
public async Task<Result<TicketDetailsDto>> HandleAsync(
ChangeTicketStatusCommand? command, CancellationToken ct = default)
}
Returns the updated ticket as TicketDetailsDto (same as GetTicketById).
Shared Application Types
public sealed record TicketSearchCriteria(
string? Text = null,
IReadOnlyCollection<TicketStatus>? Statuses = null,
IReadOnlyCollection<TicketPriority>? Priorities = null,
DateTimeOffset? CreatedFrom = null,
DateTimeOffset? CreatedTo = null,
DateTimeOffset? DueFrom = null,
DateTimeOffset? DueTo = null);
public readonly record struct Paging(int Page, int PageSize)
{
public int Skip => (Page - 1) * PageSize;
}
public enum SortDirection { Asc, Desc }
public enum TicketSortField { CreatedAt, DueAt, Priority, Status, Title }
public readonly record struct SortSpec(TicketSortField Field, SortDirection Direction)
{
public static SortSpec Default => new(TicketSortField.CreatedAt, SortDirection.Desc);
}
public sealed record PagedResult<T>(
IReadOnlyList<T> Items,
int TotalCount,
Paging Paging);
public record TicketListItemDto(
TicketId Id,
string Title,
TicketStatus Status,
TicketPriority Priority,
DateTimeOffset CreatedAt,
DateTimeOffset? DueAt);
Paging Policy Constants
public static class PagingPolicy
{
public const int MinPage = 1;
public const int MinPageSize = 1;
public const int DefaultPageSize = 25;
public const int MaxPageSize = 200;
}
Exception Mappers
Two helper classes centralise the translation from caught exceptions to ApplicationError:
// Domain layer exceptions → ApplicationError.DomainViolation
public static class DomainExceptionMapper
{
public static ApplicationError ToApplicationError(DomainException ex)
}
// Persistence layer exceptions → ApplicationError.Conflict or ApplicationError.Unexpected
// Conflict is detected by inspecting the exception message for keywords
// ("already exists", "duplicate", "unique", "concurrency").
public static class PersistenceExceptionMapper
{
public static ApplicationError ToApplicationError(Exception ex)
}
Handlers use these instead of duplicating the mapping logic inline.
Repository & Unit of Work Abstractions
public interface ITicketRepository
{
Task AddAsync(Ticket ticket, CancellationToken ct = default);
Task<Ticket?> GetByIdAsync(TicketId id, CancellationToken ct = default);
Task<bool> ExistsAsync(TicketId id, CancellationToken ct = default);
Task<PagedResult<Ticket>> SearchAsync(
TicketSearchCriteria criteria,
Paging paging,
SortSpec sort,
CancellationToken ct = default);
}
public interface IUnitOfWork
{
Task SaveChangesAsync(CancellationToken ct = default);
}