Skip to content
bitzorcas
中EN

Tutorial

Building Your First Vertical Slice: LegalTech Matter Intake Hands-on Tutorial

Master BitzOrcas.Modern Vertical Slice Architecture through a real-world civil litigation matter intake use case: inheriting TenantAggregateRoot, [BitzTable] metadata, [GenerateEndpoint] route binding, IAuthorizedRequest policy declaration, and ICommandRepository persistence.

Last updated

In traditional layered architectures, development teams inevitably succumb to “boilerplate glue hell”: adding a simple litigation matter intake feature requires jumping across 6 to 8 disparate files—MatterController, IMatterService, MatterServiceImpl, CreateMatterDto, MatterMapper, MatterEntity, IMatterRepository, and MatterRepositoryImpl. Even minor schema updates cascade across every horizontal layer, escalating cognitive load and causing frequent Git merge conflicts across collaborating engineers.

BitzOrcas.Modern replaces this outdated horizontal layering with Vertical Slice Architecture. Adhering to the principle of “One Use Case, One File,” Minimal API endpoint route declarations, declarative authorization metadata, command contracts, 4-tier validation rules, and business handlers are unified into self-contained compilation units.

This tutorial guides you through building a production-ready vertical slice grounded in a real-world Law Firm Civil Litigation Matter Intake scenario.

Vertical Slice Request Lifecycle

"SQL Server (LegalMatterIntake Table)""ICommandRepository<MatterIntake, string>""MatterIntake (TenantAggregateRoot)""CreateMatterIntakeCommandHandler""Mediator 10-Tier Pipeline""Minimal API ([GenerateEndpoint])""SQL Server (LegalMatterIntake Table)""ICommandRepository<MatterIntake, string>""MatterIntake (TenantAggregateRoot)""CreateMatterIntakeCommandHandler""Mediator 10-Tier Pipeline""Minimal API ([GenerateEndpoint])"Pipeline Phase 1: Transparent Auditing, Auth, Validation & LockingPipeline Phase 2: Atomic Transaction & Event Outbox"Legal Client / Web SPA"1. POST /api/legal/matters (JWT & Request Payload)12. Dispatch CreateMatterIntakeCommand23. Bind traceparent logging context (LoggingBehavior)34. Evaluate IAuthorizedRequest permissions (AuthorizationBehavior)45. Execute Tier 1 invariant validation (IRequestRule)56. Acquire Redis distributed idempotency lock (IdempotencyBehavior)67. Forward sanitized, validated Command payload78. Validate active tenant boundary via ICurrentTenant89. Invoke MatterIntake.Create(...) domain invariant guards910. Instantiate aggregate root and append MatterIntakeCreatedEvent1011. Invoke repository.SaveAsync(matter, ct)1112. Return Result<string>.Success(matter.Id)1213. Open database local transaction and commit entity diff1314. Persist domain events to CAP Outbox table atomically1415. Return HTTP 200 OK with business matter ID15"Legal Client / Web SPA"

Step 1: Create the Domain Unified Aggregate Root

In BitzOrcas.Modern, domain models inherit directly from the framework’s TenantAggregateRoot<TId> base class, which provides:

  • Id: Globally unique entity identity;
  • TenantId: Multi-tenant physical/logical partition key (via ITenantEntity);
  • Audit Envelopes: CreateTime, CreateBy, UpdateTime, UpdateBy (implementing IAuditableEntity);
  • Soft Deletion & Concurrency: IsDeleted, DeleteTime, RowVersion (implementing ISoftDelete and IConcurrencyTracked);
  • Domain Event Collection: Built-in Raise(IDomainEvent) method.

Create domain error contracts and the aggregate root model, decorated with vendor-neutral [BitzTable] and [BitzColumn] attributes:

src/Modules/Business/Legal/Contracts/LegalErrors.cs
using BitzOrcas.Domain.Results;
namespace BitzOrcas.Modules.Legal.Domain;
/// <summary>
/// Static domain error catalog for legal matter intake.
/// </summary>
public static class LegalErrors
{
/// <summary>
/// Client and opposing party conflict of interest
/// </summary>
public static readonly Error AdversaryConflict =
Error.Conflict("Legal.AdversaryConflict", "Client entity and opposing party cannot be identical.");
/// <summary>
/// Claim amount must be positive
/// </summary>
public static readonly Error InvalidClaimAmount =
Error.Validation("Legal.InvalidClaimAmount", "Litigation claim amount must be strictly greater than zero.");
/// <summary>
/// Tenant context is required
/// </summary>
public static readonly Error TenantRequired =
Error.Unauthorized("Tenant.Required", "Active tenant context missing or unresolved by tenancy chain.");
/// <summary>
/// Matter title is invalid
/// </summary>
public static readonly Error InvalidTitle =
Error.Validation("Legal.Validation.InvalidTitle", "Matter title cannot be empty or exceed 200 characters.");
/// <summary>
/// Both retaining client and opposing party are required
/// </summary>
public static readonly Error PartiesRequired =
Error.Validation("Legal.Validation.PartiesRequired", "Both retaining client and opposing party are mandatory.");
}
src/Modules/Business/Legal/Domain/MatterIntake.cs
using System;
using System.ComponentModel;
using BitzOrcas.Domain.Entities;
using BitzOrcas.Domain.Results;
using BitzOrcas.Persistence.Metadata;
namespace BitzOrcas.Modules.Legal.Domain;
/// <summary>
/// Matter intake unified aggregate root.
/// </summary>
/// <remarks>
/// Inherits TenantAggregateRoot to acquire tenancy isolation, auditing, and soft deletion.
/// Roslyn incremental source generators emit dual ORM (SqlSugar / EF Core) mappings at compile time.
/// </remarks>
[BitzTable("LegalMatterIntake", IsTenant = true, IsSoftDelete = true, Description = "Civil Litigation Matter Intake")]
public sealed class MatterIntake : TenantAggregateRoot<string>
{
private const int CodeMaxLength = 32;
private const int TitleMaxLength = 200;
private const int PartyNameMaxLength = 100;
/// <summary>
/// External business matter code (e.g. MAT-20260923-0001).
/// </summary>
[BitzColumn(Length = CodeMaxLength, IsRequired = true, IsUnique = true, Description = "Matter Code")]
public string MatterCode { get; private set; } = string.Empty;
/// <summary>
/// Descriptive title of the legal dispute.
/// </summary>
[BitzColumn(Length = TitleMaxLength, IsRequired = true, Description = "Matter Title")]
public string MatterTitle { get; private set; } = string.Empty;
/// <summary>
/// Retaining client legal entity name.
/// </summary>
[BitzColumn(Length = PartyNameMaxLength, IsRequired = true, Description = "Client Legal Name")]
public string ClientName { get; private set; } = string.Empty;
/// <summary>
/// Opposing party name used for adversary conflict of interest checks.
/// </summary>
[BitzColumn(Length = PartyNameMaxLength, IsRequired = true, Description = "Opposing Party Name")]
public string OpposingParty { get; private set; } = string.Empty;
/// <summary>
/// Monetary claim value in dispute.
/// </summary>
[BitzColumn(Precision = 18, Scale = 2, IsRequired = true, Description = "Claim Amount")]
public decimal ClaimAmount { get; private set; }
/// <summary>
/// Parameterless constructor reserved strictly for ORM materialization.
/// </summary>
[Obsolete("For ORM materialization only. Use Create.", error: true)]
[EditorBrowsable(EditorBrowsableState.Never)]
public MatterIntake() : base("0") { }
private MatterIntake(
string id,
string matterCode,
string matterTitle,
string clientName,
string opposingParty,
decimal claimAmount,
string tenantId) : base(id)
{
MatterCode = matterCode;
MatterTitle = matterTitle;
ClientName = clientName;
OpposingParty = opposingParty;
ClaimAmount = claimAmount;
TenantId = tenantId;
}
/// <summary>
/// Factory method enforcing domain business invariants prior to instantiation.
/// </summary>
public static Result<MatterIntake> Create(
string id,
string matterCode,
string matterTitle,
string clientName,
string opposingParty,
decimal claimAmount,
string tenantId)
{
// Invariant 1: Client and adversary cannot be the same entity
if (string.Equals(clientName.Trim(), opposingParty.Trim(), StringComparison.OrdinalIgnoreCase))
{
return Result<MatterIntake>.Failure(LegalErrors.AdversaryConflict);
}
// Invariant 2: Monetary claim value must be strictly positive
if (claimAmount <= 0)
{
return Result<MatterIntake>.Failure(LegalErrors.InvalidClaimAmount);
}
var intake = new MatterIntake(
id,
matterCode.Trim(),
matterTitle.Trim(),
clientName.Trim(),
opposingParty.Trim(),
claimAmount,
tenantId);
return Result<MatterIntake>.Success(intake);
}
}

Step 2: Implement the Command and Handler (One File Use Case)

In BitzOrcas.Modern, Minimal API endpoint generation, declarative authorization rules, command definitions, and handlers reside within a unified slice file.

Create CreateMatterIntakeCommand.cs in src/Modules/Business/Legal/Application/Commands/CreateMatterIntake/:

src/Modules/Business/Legal/Application/Commands/CreateMatterIntake/CreateMatterIntakeCommand.cs
using BitzOrcas.Application.Abstractions.Tenancy;
using BitzOrcas.Application.Authorization;
using BitzOrcas.Domain.Abstractions;
using BitzOrcas.Domain.Results;
using BitzOrcas.Endpoint.Attributes;
using BitzOrcas.Modules.Legal.Domain;
using Mediator;
namespace BitzOrcas.Modules.Legal.Application.Commands.CreateMatterIntake;
/// <summary>
/// Matter intake command model.
/// </summary>
/// <remarks>
/// Decorated with [GenerateEndpoint] for compile-time Minimal API route synthesis.
/// Implements IAuthorizedRequest for transparent RBAC policy enforcement by the pipeline.
/// </remarks>
[GenerateEndpoint(HttpRoute.Post, "/api/legal/matters", Tag = "LegalMatter")]
public sealed record CreateMatterIntakeCommand(
string MatterTitle,
string ClientName,
string OpposingParty,
decimal ClaimAmount) : ICommand<Result<string>>, IAuthorizedRequest
{
/// <summary>
/// Protected resource: Legal domain Matter resource.
/// </summary>
public ResourceDescriptor Resource { get; } = new("Legal", "Matter");
/// <summary>
/// Declared operational action: Create.
/// </summary>
public AuthorizationAction Action { get; } = AuthorizationAction.Create;
}
/// <summary>
/// Matter intake orchestration handler.
/// </summary>
/// <remarks>
/// Injects generic narrow command repository ICommandRepository without leaking ORM internals.
/// Transactions are managed transparently by the 10-tier application pipeline.
/// </remarks>
public sealed class CreateMatterIntakeCommandHandler(
ICommandRepository<MatterIntake, string> repository,
ICurrentTenant currentTenant) : ICommandHandler<CreateMatterIntakeCommand, Result<string>>
{
/// <summary>
/// Executes matter intake workflow.
/// </summary>
public async ValueTask<Result<string>> Handle(
CreateMatterIntakeCommand request,
CancellationToken cancellationToken)
{
// 1. Verify valid multi-tenant execution context
var tenant = currentTenant.Tenant;
if (!tenant.IsAvailable)
{
return Result<string>.Failure(LegalErrors.TenantRequired);
}
// 2. Generate deterministic entity ID and business tracking code
var matterId = Guid.NewGuid().ToString("N");
var matterCode = $"MAT-{DateTime.UtcNow:yyyyMMdd}-{matterId[..6].ToUpperInvariant()}";
// 3. Delegate invariant validation to domain aggregate factory
var createResult = MatterIntake.Create(
matterId,
matterCode,
request.MatterTitle,
request.ClientName,
request.OpposingParty,
request.ClaimAmount,
tenant.EffectiveTenantId);
if (createResult.IsFailure)
{
return Result<string>.Failure(createResult.Error);
}
var matter = createResult.GetValueOrThrow();
// 4. Save aggregate through ICommandRepository; transaction pipeline commits atomically
var saveResult = await repository.SaveAsync(matter, cancellationToken);
if (saveResult.IsFailure)
{
return Result<string>.Failure(saveResult.Error);
}
// 5. Return new entity ID
return Result<string>.Success(matter.Id);
}
}

Step 3: Implement Tier 1 Static Validation Rules (IRequestRule)

BitzOrcas enforces a 4-tier validation hierarchy. Basic invariant checks—such as string length bounds and non-null constraints—are encapsulated in an IRequestRule<TRequest> executed automatically before reaching the handler:

src/Modules/Business/Legal/Application/Commands/CreateMatterIntake/CreateMatterIntakeCommandRule.cs
using BitzOrcas.Application.Validation;
using BitzOrcas.Domain.Results;
using BitzOrcas.Modules.Legal.Domain;
namespace BitzOrcas.Modules.Legal.Application.Commands.CreateMatterIntake;
/// <summary>
/// Tier 1 invariant validation rule for matter intake command.
/// </summary>
public sealed class CreateMatterIntakeCommandRule : IRequestRule<CreateMatterIntakeCommand>
{
public ValueTask<Result> ValidateAsync(CreateMatterIntakeCommand request, CancellationToken cancellationToken)
{
// Validate required title and length
if (string.IsNullOrWhiteSpace(request.MatterTitle) || request.MatterTitle.Length > 200)
{
return ValueTask.FromResult(Result.Failure(LegalErrors.InvalidTitle));
}
// Validate party names
if (string.IsNullOrWhiteSpace(request.ClientName) || string.IsNullOrWhiteSpace(request.OpposingParty))
{
return ValueTask.FromResult(Result.Failure(LegalErrors.PartiesRequired));
}
// Validate positive claim amount
if (request.ClaimAmount <= 0)
{
return ValueTask.FromResult(Result.Failure(LegalErrors.InvalidClaimAmount));
}
return ValueTask.FromResult(Result.Success());
}
}

Step 4: End-to-End Integration Testing (Machine Verification)

Add integration tests in tests/BitzOrcas.Integration.Tests/Legal/ to assert slice execution through the real HTTP pipeline:

tests/BitzOrcas.Integration.Tests/Legal/MatterIntakeSliceTests.cs
using System.Net;
using System.Net.Http.Json;
using BitzOrcas.Domain.Results;
using BitzOrcas.Modules.Legal.Application.Commands.CreateMatterIntake;
using BitzOrcas.Modules.Legal.Domain;
using Shouldly;
using Xunit;
public sealed class MatterIntakeSliceTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly HttpClient _client;
public MatterIntakeSliceTests(CustomWebApplicationFactory factory)
{
// Obtain authenticated HTTP client populated with valid test JWT credentials
_client = factory.CreateAuthenticatedClient(tenantId: "1000001", role: "SuperAdmin");
}
[Fact]
public async Task CreateMatter_WithValidPayload_ShouldReturnSuccessWithGeneratedId()
{
// 1. Arrange realistic litigation matter intake payload
var command = new CreateMatterIntakeCommand(
MatterTitle: "Cross-Border Trade Secret Infringement Injunction",
ClientName: "Apex Semiconductor Technologies Corp.",
OpposingParty: "Former VP of Engineering & Entity B Corp",
ClaimAmount: 15_000_000.00m);
// 2. Dispatch POST request through Minimal API pipeline
var response = await _client.PostAsJsonAsync("/api/legal/matters", command);
// 3. Machine assertion: assert HTTP 200 OK
response.StatusCode.ShouldBe(HttpStatusCode.OK);
// 4. Assert unified Result<string> response contains valid identifier
var result = await response.Content.ReadFromJsonAsync<Result<string>>();
result.ShouldNotBeNull();
result.IsSuccess.ShouldBeTrue();
result.Value.ShouldNotBeNullOrWhiteSpace();
}
[Fact]
public async Task CreateMatter_WithSameClientAndOpponent_ShouldFailWithAdversaryConflictError()
{
// 1. Construct conflicted payload with identical client and opponent
var conflictCommand = new CreateMatterIntakeCommand(
MatterTitle: "Shareholder Resolution Validity Injunction",
ClientName: "Holdings Group LLC",
OpposingParty: "Holdings Group LLC",
ClaimAmount: 1_000_000.00m);
// 2. Dispatch request
var response = await _client.PostAsJsonAsync("/api/legal/matters", conflictCommand);
// 3. Assert failure: pipeline short-circuits with RFC 9457 Problem Details
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
var result = await response.Content.ReadFromJsonAsync<Result<string>>();
result.ShouldNotBeNull();
result.IsFailure.ShouldBeTrue();
result.Error.Code.ShouldBe(LegalErrors.AdversaryConflict.Code);
}
}

Architectural Review & Benefits

Following this tutorial, your vertical slice achieves enterprise-grade quality:

  1. Zero Boilerplate: Eliminated glue classes (Controller, Service, Repository); logic is encapsulated within the use case itself.
  2. Zero-Reflection Assembly: [GenerateEndpoint] and [BitzTable] execute at compile time, guaranteeing full compatibility with .NET 10 Native AOT trimming.
  3. Transparent Governance: Logging, RBAC authorization, multi-tier validation, database transactions, and CAP Outbox publishing are handled implicitly by the 10-tier application pipeline.

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%