Backend Development Guide
July 25, 2026 · View on GitHub
Patterns and conventions for .NET backend development.
Project Structure
src/Core/Logistics.Application/
├── Modules/ # Feature code grouped by bounded context
│ ├── Operations/ # Loads, Trips, Trucks, Containers, Terminals, …
│ │ └── Loads/
│ │ ├── Commands/CreateLoad/
│ │ ├── Queries/GetLoadById/
│ │ └── Services/ # feature-scoped workflow services (e.g. ILoadService)
│ ├── Compliance/ # ELD, DVIR, Accidents, Safety, Privacy, Ifta
│ ├── Financial/ # Invoices, Payments, Payroll, Expenses, Tax
│ ├── IdentityAccess/ # Users, Tenants, Roles, Subscriptions
│ ├── Integrations/ # AIDispatch, LoadBoard, FuelCards, Accounting, Webhooks, Messaging, Documents
│ └── Platform/ # Stats, Reports, BlogPosts, Notifications
├── Handlers/ # Generic base handlers (Delete/GetById/Update) that trivial slices subclass
├── Behaviours/ # MediatR pipeline
└── Validators/ # Shared FluentValidation base validators (e.g. AddressValidator)
src/Core/Logistics.Application.Abstractions/
└── (Infrastructure ports grouped by domain: AIDispatch, Storage, Geocoding, Eld, Payments, …)
src/Core/Logistics.Domain/
├── Entities/ # Domain entities
├── ValueObjects/ # Value objects
├── Events/ # Domain events
├── Specifications/ # Query specifications
└── Interfaces/ # Abstractions
src/Infrastructure/
├── Logistics.Infrastructure.Persistence/
│ ├── Data/ # DbContext, migrations
│ ├── Persistence/ # Repositories, UnitOfWork
│ └── Services/ # Multi-tenancy services
│
├── Logistics.Infrastructure.Communications/
│ ├── SignalR/ # Hubs, real-time services
│ ├── Email/ # Email services
│ └── Notifications/ # Push notifications
│
├── Logistics.Infrastructure.AI/ # LLM providers, dispatch agent, tool registry
│
├── Logistics.Infrastructure.Integrations.Common/
│ └── HttpClientJsonExtensions.cs # Shared HTTP JSON helpers; leaf project, no repo references
│
├── Logistics.Infrastructure.Integrations.Eld/
│ └── Providers/ # Samsara, Motive, TtEld, Geotab, Demo
│
├── Logistics.Infrastructure.Integrations.LoadBoard/
│ └── Providers/ # DAT, Truckstop, 123Loadboard, Demo
│
├── Logistics.Infrastructure.Integrations.FuelCards/
│ └── Providers/ # Wex, Efs, Demo
│
├── Logistics.Infrastructure.Integrations.Accounting/
│ └── Providers/ # QuickBooks, Demo
│
├── Logistics.Infrastructure.Payments/
│ └── Stripe/ # Stripe, Stripe Connect
│
├── Logistics.Infrastructure.Documents/
│ └── Pdf/ # PDF generation, template import
│
├── Logistics.Infrastructure.Routing/
│ └── Trip/ # Trip optimization, geocoding
│
├── Logistics.Infrastructure.Storage/
│ └── (Blob storage implementations: Azure Blob, Cloudflare R2, local file system)
│
├── Logistics.Infrastructure.Tax/
│ ├── Stripe/ # Stripe Tax calculator and config
│ ├── Manual/ # Manual tax calculator
│ └── Data/ # US sales tax, EU VAT, other-country rates
│
└── Logistics.Infrastructure.Vin/
└── (VIN decoder: WMI prefix lookup + NHTSA API)
Adding a New Feature
Step 1: Create Command/Query
Commands and queries are the request contract - they are bound directly as the request body (flat
properties, no *Dto wrapper). Add a dedicated *Request record only when the wire shape must differ
from the command; see api-design.md.
// Modules/Operations/Loads/Commands/CreateLoad/CreateLoadCommand.cs
public class CreateLoadCommand : ICommand // ICommand<Result<T>> when the client needs data back
{
public string Name { get; set; } = null!;
public Guid CustomerId { get; set; }
public Address OriginAddress { get; set; } = null!;
public Address DestinationAddress { get; set; } = null!;
// …flat fields, value objects bind as nested objects
}
internal sealed class CreateLoadHandler(ITenantUnitOfWork unitOfWork)
: IRequestHandler<CreateLoadCommand, Result>
{
public async Task<Result> Handle(CreateLoadCommand req, CancellationToken ct)
{
var load = Load.Create(req.Name, req.CustomerId, req.OriginAddress, req.DestinationAddress);
await unitOfWork.Repository<Load>().AddAsync(load, ct);
await unitOfWork.SaveChangesAsync(ct);
return Result.Ok();
}
}
Trivial Delete/GetById/Update slices don't need a hand-written handler - subclass the generic base
handlers in Logistics.Application/Handlers/ (DeleteTenantEntityHandler, GetTenantEntityByIdHandler,
UpdateTenantEntityHandler; master variants exist too). A command targets the master or tenant DB purely
by which unit of work its handler injects (IMasterUnitOfWork vs ITenantUnitOfWork) - there is no
separate marker interface.
Step 2: Add Validation
Skip the validator entirely when its only rule would be Id NotEmpty (the handler's not-found path
already covers it). For Create/Update pairs sharing rules, use a shared-fields base validator. See
csharp-conventions.md.
public class CreateLoadValidator : AbstractValidator<CreateLoadCommand>
{
public CreateLoadValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.CustomerId).NotEmpty();
}
}
Step 3: Create Controller Endpoint
Literal plural-lowercase route, primary-constructor DI, policy-based authorization per action. Full rules in api-design.md.
// Controllers/LoadController.cs
[Route("loads")]
[Produces("application/json")]
public class LoadController(IMediator mediator) : ControllerBase
{
[HttpPost(Name = "CreateLoad")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
[Authorize(Policy = Permission.Load.Manage)]
public async Task<IActionResult> Create([FromBody] CreateLoadCommand request)
{
var result = await mediator.Send(request);
return result.IsSuccess ? NoContent() : BadRequest(ErrorResponse.FromResult(result));
}
}
Step 4: Define DTO
DTOs are the read shape, returned by queries and produced by Mapperly mappers (never mapped by hand -
see mapperly.md). Entity ids are Guid.
// Logistics.Shared.Models/LoadDto.cs
public record LoadDto
{
public Guid Id { get; init; }
public Guid CustomerId { get; init; }
public string Name { get; init; } = string.Empty;
public LoadStatus Status { get; init; }
public DateTime CreatedDate { get; init; }
}
Domain Entities
Entity Base Class
public abstract class Entity : IEntity<Guid>
{
public Guid Id { get; set; } = Guid.NewGuid();
}
// AuditableEntity adds CreatedDate / LastModifiedDate; inherit it for almost everything.
public abstract class AggregateRoot : Entity
{
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents;
protected void AddDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void ClearDomainEvents() => _domainEvents.Clear();
}
Example Entity
public class Load : AggregateRoot
{
public string Name { get; private set; } = null!;
public Guid CustomerId { get; private set; }
public LoadStatus Status { get; private set; }
private Load() { } // EF Core
public static Load Create(string name, Guid customerId, Address origin, Address destination)
{
var load = new Load
{
Name = name,
CustomerId = customerId,
Status = LoadStatus.Pending
};
load.AddDomainEvent(new LoadCreatedEvent(load));
return load;
}
public void Complete()
{
if (Status == LoadStatus.Completed)
throw new InvalidOperationException("Load already completed");
Status = LoadStatus.Completed;
AddDomainEvent(new LoadCompletedEvent(this));
}
}
Specifications
Most reads use an inline .Query() inside the handler now. Write a dedicated Specification<T> class
only when the same query condition is reused across handlers (there are just a few left, e.g.
FilterLoadInvoices, FilterPayrollInvoices, FilterLoadsByDeliveryDate).
public class ActiveLoadsByCustomerSpec : Specification<Load>
{
public ActiveLoadsByCustomerSpec(Guid customerId)
{
Query
.Where(l => l.CustomerId == customerId)
.Where(l => l.Status == LoadStatus.Active)
.OrderByDescending(l => l.CreatedDate);
}
}
// Usage
var loads = await unitOfWork.Repository<Load>()
.GetListAsync(new ActiveLoadsByCustomerSpec(customerId), ct);
Domain Events
// Define event
public record LoadCompletedEvent(Load Load) : IDomainEvent;
// Handle event
public class LoadCompletedHandler : INotificationHandler<LoadCompletedEvent>
{
private readonly IInvoiceService _invoiceService;
public async Task Handle(LoadCompletedEvent notification, CancellationToken ct)
{
await _invoiceService.GenerateInvoiceAsync(notification.Load);
}
}
Background Jobs
Recurring Hangfire jobs live in src/Presentation/Logistics.API/Jobs/. The canonical rule is in CLAUDE.md: fan out with TenantJobRunner.ForEachTenantAsync (never hand-roll the loop), and because jobs bypass the MediatR pipeline the body must gate features itself with IFeatureService ([RequiresFeature] is inert). Keep the feature check inside the body - a job may still do some work for every tenant regardless of the flag, as IftaQuarterCloseJob does when it purges breadcrumbs for tenants without IFTA enabled. The worked pattern:
[AutomaticRetry(Attempts = 2)]
public Task SyncAllTenantsAsync(CancellationToken ct) =>
TenantJobRunner.ForEachTenantAsync(scopeFactory, logger, "fuel card sync", SyncTenantAsync, ct);
private async Task SyncTenantAsync(IServiceScope scope, Tenant tenant, CancellationToken ct)
{
var featureService = scope.ServiceProvider.GetRequiredService<IFeatureService>();
if (!await featureService.IsFeatureEnabledAsync(tenant.Id, TenantFeature.FuelCards))
{
return;
}
// …
}
Testing
xUnit + NSubstitute (not Moq). The system under test is named sut; private fields are camelCase with
no _ prefix. Full conventions in testing.md.
public class CreateLoadHandlerTests
{
private readonly ITenantUnitOfWork tenantUow = Substitute.For<ITenantUnitOfWork>();
private readonly CreateLoadHandler sut;
public CreateLoadHandlerTests() => sut = new CreateLoadHandler(tenantUow);
[Fact]
public async Task Handle_ValidLoad_ReturnsSuccess()
{
// Arrange
var command = new CreateLoadCommand
{
Name = "ACME-42",
CustomerId = Guid.NewGuid()
};
tenantUow.SaveChangesAsync().Returns(1);
// Act
var result = await sut.Handle(command, CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
await tenantUow.Repository<Load>().Received(1).AddAsync(Arg.Any<Load>());
}
}
Conventions
- Naming (Commands/Queries/Handlers/Validators/DTOs/Mappers): see csharp-conventions.md.
- Response types:
Result<T>for single items,PagedResult<T>for lists. - Error handling: throw domain exceptions for business-rule violations; return
Result.Fail(...)for expected failures, and when the client must branch on the failure use the machine-readable-code overload. See api-design.md. Let middleware handle unexpected exceptions.
Next Steps
- Angular Guide - Frontend patterns
- Testing rules - Test strategies