system-design56 min read

Design a Legal Case Management System — A Senior+ Guide | Ayodhyya

Design a Legal Case Management System

Building enterprise-grade legal software: case lifecycle, document management, billing, compliance, and workflow automation

Senior+ Guide 50+ min read 10,000+ words Ayodhyya

1. Introduction — The Legal Tech Landscape

Legal case management systems represent one of the most mission-critical categories of enterprise software in existence. Every law firm, corporate legal department, government agency, and public defender office relies on some form of case tracking to manage their workflows, meet court deadlines, bill clients accurately, and maintain compliance with strict regulatory obligations. The consequences of failure in a legal case management system are uniquely severe: missed filing deadlines can result in case dismissals, lost documents can trigger malpractice claims, and billing errors can lead to bar association sanctions.

The legal technology industry has undergone a dramatic transformation over the past decade. The global legal tech market was valued at approximately 28 billion dollars in 2024 and is projected to exceed 60 billion dollars by 2030. This growth is being driven by several converging forces. First, the COVID-19 pandemic permanently accelerated the adoption of digital tools across the legal profession, with courts now routinely accepting electronic filings and remote depositions becoming standard practice. Second, the increasing complexity of regulatory compliance requirements across jurisdictions has made manual case management untenable for firms handling large caseloads. Third, the rise of alternative legal service providers and the pressure to reduce billing rates has forced traditional firms to invest in technology that improves efficiency.

Modern legal case management systems have evolved far beyond simple matter tracking databases. Today's platforms integrate document management with version control and full-text search, automated deadline calendaring that accounts for jurisdictional rules, time tracking with LEDES billing format export, client portals with secure messaging, workflow engines that automate routine legal processes, conflict of interest checking across entire firm histories, and advanced analytics dashboards that provide insights into case outcomes and firm performance. Building such a system requires deep expertise in distributed systems, security engineering, document processing, and the intricate business rules that govern legal practice.

In this comprehensive guide, we will design a legal case management system from the ground up. We will cover every major subsystem including case lifecycle management, document storage and versioning, court deadline calendaring, time billing and invoicing, client portal architecture, full-text search, workflow automation, conflict checking, multi-tenant support for law firms, audit trails for compliance, notification systems, reporting and analytics, and the critical security and encryption requirements that legal data demands. Throughout the guide, we will provide production-quality C# code examples, detailed data models, Mermaid architecture diagrams, and practical design decisions drawn from real-world implementations.

Who This Guide Is For: This guide targets senior software engineers, architects, and tech leads who are building or evaluating legal case management platforms. It assumes familiarity with distributed systems, relational databases, and enterprise software patterns. We use C# and .NET throughout as the primary implementation language, though the architectural principles apply to any technology stack.

2. Functional & Non-Functional Requirements

Before designing any system, we must clearly define what it needs to do and how well it needs to perform. Legal case management systems have a uniquely broad functional scope that spans matter management, document handling, calendaring, billing, client communication, and regulatory compliance. Let us enumerate both the functional and non-functional requirements that will drive our architecture.

Functional Requirements

  • Matter (Case) Management: Create, update, archive, and reopen legal matters. Each matter tracks parties involved, case type, jurisdiction, assigned attorneys, status, and associated documents. Matters follow a defined lifecycle from intake through resolution.
  • Document Management: Upload, version, tag, and search legal documents including pleadings, contracts, correspondence, discovery materials, and evidence. Support for PDF, Word, Excel, and image formats with full-text extraction and OCR for scanned documents.
  • Deadline and Calendar Management: Automatically calculate and track court filing deadlines based on jurisdictional rules, statutes of limitations, and court-specific calendars. Send multi-channel reminders at configurable intervals before each deadline.
  • Time Tracking and Billing: Record billable hours and expenses at the task and matter level. Generate invoices in LEDES, UTBMS, and custom formats. Support both hourly and fixed-fee billing arrangements with trust account management.
  • Client Portal: Provide a secure web portal where clients can view case status, access shared documents, send messages to their legal team, review and approve invoices, and track case progress through visual timelines.
  • Search and Discovery: Full-text search across all case data, documents, communications, and metadata with faceted filtering by case type, date range, attorney, status, jurisdiction, and practice area.
  • Workflow Automation: Define and execute configurable workflows for common legal processes such as new matter intake, document review, discovery management, and case closure. Support conditional branching, parallel tasks, and escalation rules.
  • Conflict of Interest Check: Automatically screen new matters against the firm's entire history of parties, opposing counsel, and related entities to identify potential conflicts before engagement.
  • Multi-Tenant Support: Support multiple law firms or legal departments on a shared platform with complete data isolation, configurable branding, and tenant-specific business rules.
  • Reporting and Analytics: Generate reports on case outcomes, billing summaries, attorney utilization, deadline compliance, and client satisfaction. Provide dashboards with real-time KPIs.
  • Audit Trail: Maintain an immutable, timestamped log of every action performed on cases, documents, and billing records for compliance with legal data retention requirements and e-discovery obligations.

Non-Functional Requirements

RequirementTargetRationale
Availability99.99% (52 min downtime/year)Missed deadlines can result in case loss; system must be always available
Latency (read)p99 < 200msAttorneys need fast access to case data during court proceedings
Latency (write)p99 < 500msDocument uploads and time entries must feel responsive
Document Storage100 TB+ per tenantLarge litigation matters can generate terabytes of discovery documents
Concurrent Users50,000+ per tenantLarge firms may have thousands of attorneys plus staff accessing simultaneously
Data Retention10+ yearsLegal data retention requirements mandate long-term storage
EncryptionAES-256 at rest, TLS 1.3 in transitAttorney-client privilege requires strongest encryption standards
ComplianceSOC 2 Type II, HIPAA, GDPRLegal data often includes protected health and personal information
Critical Design Constraint: Unlike many SaaS applications where brief downtime is acceptable, a legal case management system has zero tolerance for data loss and extremely tight availability requirements. A single missed court deadline due to system unavailability can result in malpractice liability measured in millions of dollars. Every architectural decision must account for this reality.

3. Capacity Estimation & Back-of-Envelope

Let us estimate the storage and throughput requirements for a mid-to-large legal case management platform serving multiple law firms. These estimates will drive our database design, caching strategy, and infrastructure provisioning decisions.

Storage Estimation

Consider a platform serving 500 law firms with an average of 200 matters per firm active at any time. This gives us 100,000 active matters. Each matter averages 50 documents over its lifetime, with an average document size of 2 MB. This yields approximately 100,000 matters times 50 documents times 2 MB equals 10 TB of document storage. Adding metadata, audit logs, and search indices, we estimate total storage at approximately 15 TB for active data, growing to 50 TB including archived matters over a 5-year period.

Write Throughput

Assuming each firm creates 5 new matters per week and each matter generates approximately 10 document uploads per week, we have 500 firms times 5 matters times 10 documents equals 25,000 document uploads per week, or approximately 500 uploads per hour during business hours. With peak traffic concentrated during 9 AM to 6 PM on weekdays, we estimate peak write throughput at approximately 2,000 writes per second for documents, plus 5,000 writes per second for metadata updates, time entries, and status changes.

Read Throughput

Attorneys access case data frequently throughout the day. With 10,000 active attorneys performing an average of 50 page loads per day, we get 500,000 page views per day, or approximately 10 reads per second on average. During peak morning hours when attorneys are preparing for court, this can spike to 50 reads per second. Document downloads add another 20 reads per second at peak.

MetricValue
Active Matters100,000
Total Documents5,000,000
Document Storage10 TB (active), 50 TB (total)
Write Throughput (peak)7,000 ops/sec
Read Throughput (peak)70 ops/sec
Search Queries (peak)200 queries/sec
Concurrent Users5,000
Data Retention10 years minimum

Bandwidth Estimation

At peak, document uploads of 2,000 per second at 2 MB average size require approximately 4 Gbps of upload bandwidth. Document downloads at 20 per second with 2 MB average require only 320 Mbps. The asymmetry reflects the nature of legal workflows where attorneys upload large batches of discovery documents but typically view one document at a time. CDN caching for frequently accessed documents can further reduce origin bandwidth requirements by an estimated 60 to 70 percent.

4. Data Model & Storage Schema

The data model for a legal case management system is fundamentally relational, with documents stored as binary objects in object storage and search indices maintained in a separate search engine. The core entities include tenants, users, matters, documents, time entries, invoices, deadlines, and audit logs. Let us define the schema in detail.

Entity Relationship Diagram

erDiagram TENANT ||--o{ USER : has TENANT ||--o{ MATTER : manages MATTER ||--o{ MATTER_DOCUMENT : contains MATTER ||--o{ TIME_ENTRY : tracks MATTER ||--o{ DEADLINE : has MATTER ||--o{ MATTER_PARTY : involves USER ||--o{ TIME_ENTRY : records USER ||--o{ MATTER : assigned_to INVOICE ||--o{ TIME_ENTRY : includes MATTER ||--o{ INVOICE : generates MATTER ||--o{ WORKFLOW_INSTANCE : triggers DOCUMENT_VERSION ||--o{ MATTER_DOCUMENT : versioned AUDIT_LOG }o--|| MATTER : logs CONFLICT_CHECK ||--o{ MATTER_PARTY : screens TENANT { uuid id PK string name string domain jsonb settings timestamp created_at } USER { uuid id PK uuid tenant_id FK string email string role string name boolean active } MATTER { uuid id PK uuid tenant_id FK string matter_number string title string case_type string status string jurisdiction uuid lead_attorney FK date opened_date date closed_date jsonb metadata } MATTER_DOCUMENT { uuid id PK uuid matter_id FK string title string file_type bigint file_size string storage_path uuid uploaded_by FK timestamp uploaded_at } DOCUMENT_VERSION { uuid id PK uuid document_id FK int version_number string storage_path string change_summary uuid created_by FK timestamp created_at } TIME_ENTRY { uuid id PK uuid matter_id FK uuid user_id FK date entry_date decimal hours decimal rate string description string task_code boolean billable uuid invoice_id FK } DEADLINE { uuid id PK uuid matter_id FK string description datetime due_date string rule_basis boolean completed datetime completed_at } INVOICE { uuid id PK uuid matter_id FK uuid tenant_id FK string invoice_number decimal total_amount string status date period_start date period_end timestamp issued_at } AUDIT_LOG { uuid id PK uuid tenant_id FK uuid matter_id FK uuid user_id FK string action string entity_type uuid entity_id jsonb old_values jsonb new_values timestamp timestamp } MATTER_PARTY { uuid id PK uuid matter_id FK string party_name string party_type string role }

C# Entity Definitions

C#
public class Tenant
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string Name { get; set; } = string.Empty;
    public string Domain { get; set; } = string.Empty;
    public TenantSettings Settings { get; set; } = new();
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public bool IsActive { get; set; } = true;
}

public class TenantSettings
{
    public string DefaultCurrency { get; set; } = "USD";
    public string DefaultTimezone { get; set; } = "America/New_York";
    public int DefaultStatuteOfLimitationsDays { get; set; } = 1095;
    public List<string> Jurisdictions { get; set; } = new();
    public BillingConfiguration Billing { get; set; } = new();
    public NotificationConfiguration Notifications { get; set; } = new();
}

public class Matter
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public Guid TenantId { get; set; }
    public string MatterNumber { get; set; } = string.Empty;
    public string Title { get; set; } = string.Empty;
    public CaseType CaseType { get; set; }
    public MatterStatus Status { get; set; } = MatterStatus.Opening;
    public string Jurisdiction { get; set; } = string.Empty;
    public Guid LeadAttorneyId { get; set; }
    public Guid ClientId { get; set; }
    public DateTime OpenedDate { get; set; } = DateTime.UtcNow;
    public DateTime? ClosedDate { get; set; }
    public string Description { get; set; } = string.Empty;
    public MatterMetadata Metadata { get; set; } = new();
    public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

public enum MatterStatus
{
    Intake, Opening, Active, OnHold, Discovery,
    Trial, Settlement, Closed, Archived
}

public enum CaseType
{
    Litigation, Transactional, Regulatory,
    IntellectualProperty, FamilyLaw, CriminalDefense,
    RealEstate, Employment, Immigration, Bankruptcy
}

public class MatterDocument
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public Guid MatterId { get; set; }
    public string Title { get; set; } = string.Empty;
    public string FileName { get; set; } = string.Empty;
    public string ContentType { get; set; } = string.Empty;
    public long FileSize { get; set; }
    public string StoragePath { get; set; } = string.Empty;
    public string ContentHash { get; set; } = string.Empty;
    public Guid UploadedById { get; set; }
    public DateTime UploadedAt { get; set; } = DateTime.UtcNow;
    public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
    public List<DocumentVersion> Versions { get; set; } = new();
    public List<string> Tags { get; set; } = new();
    public DocumentClassification Classification { get; set; }
    public bool IsDeleted { get; set; }
}

public class DocumentVersion
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public Guid DocumentId { get; set; }
    public int VersionNumber { get; set; }
    public string StoragePath { get; set; } = string.Empty;
    public string ChangeSummary { get; set; } = string.Empty;
    public Guid CreatedById { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
    public long FileSize { get; set; }
}

public enum DocumentClassification
{
    Public, Internal, Confidential, Privileged, WorkProduct
}

public class TimeEntry
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public Guid MatterId { get; set; }
    public Guid UserId { get; set; }
    public DateTime EntryDate { get; set; }
    public decimal Hours { get; set; }
    public decimal Rate { get; set; }
    public string Description { get; set; } = string.Empty;
    public string TaskCode { get; set; } = string.Empty;
    public string ActivityCode { get; set; } = string.Empty;
    public bool Billable { get; set; } = true;
    public Guid? InvoiceId { get; set; }
    public string TimekeeperId { get; set; } = string.Empty;
    public string TimekeeperName { get; set; } = string.Empty;
    public string TimekeeperClassification { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

public class CourtDeadline
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public Guid MatterId { get; set; }
    public string Description { get; set; } = string.Empty;
    public DateTime DueDate { get; set; }
    public string RuleBasis { get; set; } = string.Empty;
    public bool IsFilingDeadline { get; set; }
    public bool Completed { get; set; }
    public DateTime? CompletedAt { get; set; }
    public List<DeadlineReminder> Reminders { get; set; } = new();
}

public class DeadlineReminder
{
    public int DaysBefore { get; set; }
    public NotificationChannel Channel { get; set; }
    public bool Sent { get; set; }
}

public enum NotificationChannel
{
    Email, SMS, Push, InApp
}

public class AuditLogEntry
{
    public long Id { get; set; }
    public Guid TenantId { get; set; }
    public Guid? MatterId { get; set; }
    public Guid UserId { get; set; }
    public string Action { get; set; } = string.Empty;
    public string EntityType { get; set; } = string.Empty;
    public Guid EntityId { get; set; }
    public string? OldValues { get; set; }
    public string? NewValues { get; set; }
    public string IpAddress { get; set; } = string.Empty;
    public string UserAgent { get; set; } = string.Empty;
    public string EntryHash { get; set; } = string.Empty;
    public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}

public class MatterParty
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public Guid MatterId { get; set; }
    public string PartyName { get; set; } = string.Empty;
    public PartyType Type { get; set; }
    public string Role { get; set; } = string.Empty;
    public string? ContactInformation { get; set; }
}

public enum PartyType
{
    Individual, Corporation, Government, NonProfit, Unknown
}

public class Invoice
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public Guid MatterId { get; set; }
    public Guid TenantId { get; set; }
    public string InvoiceNumber { get; set; } = string.Empty;
    public string ClientMatterNumber { get; set; } = string.Empty;
    public string ClientMatterId { get; set; } = string.Empty;
    public string MatterTitle { get; set; } = string.Empty;
    public decimal SubTotal { get; set; }
    public decimal Tax { get; set; }
    public decimal TotalAmount { get; set; }
    public InvoiceStatus Status { get; set; } = InvoiceStatus.Draft;
    public DateTime PeriodStart { get; set; }
    public DateTime PeriodEnd { get; set; }
    public DateTime? IssuedAt { get; set; }
    public DateTime? PaidAt { get; set; }
    public List<TimeEntry> TimeEntries { get; set; } = new();
    public List<ExpenseEntry> Expenses { get; set; } = new();
}

public class ExpenseEntry
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public DateTime Date { get; set; }
    public string TimekeeperId { get; set; } = string.Empty;
    public string TimekeeperName { get; set; } = string.Empty;
    public string TimekeeperClassification { get; set; } = string.Empty;
    public string ExpenseCode { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public decimal Amount { get; set; }
}

public enum InvoiceStatus
{
    Draft, Issued, Sent, Paid, Overdue, Void
}

Database Partitioning Strategy

Given the multi-tenant nature of our system, we employ tenant-based horizontal partitioning. Each table is partitioned by TenantId, which ensures that queries for a specific tenant hit a single partition and benefit from partition pruning. The AuditLog table uses a composite partition key of TenantId plus a time-based bucket (monthly), enabling efficient time-range queries for compliance audits while keeping individual partitions manageable. Document metadata resides in PostgreSQL, while the actual binary content is stored in Azure Blob Storage or Amazon S3 with per-tenant container isolation.

C#
public class LegalDbContext : DbContext
{
    public DbSet<Tenant> Tenants { get; set; }
    public DbSet<User> Users { get; set; }
    public DbSet<Matter> Matters { get; set; }
    public DbSet<MatterDocument> Documents { get; set; }
    public DbSet<DocumentVersion> DocumentVersions { get; set; }
    public DbSet<TimeEntry> TimeEntries { get; set; }
    public DbSet<CourtDeadline> Deadlines { get; set; }
    public DbSet<Invoice> Invoices { get; set; }
    public DbSet<AuditLogEntry> AuditLogs { get; set; }
    public DbSet<MatterParty> MatterParties { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Matter>(e =>
        {
            e.HasIndex(m => new { m.TenantId, m.MatterNumber }).IsUnique();
            e.HasIndex(m => new { m.TenantId, m.Status });
            e.HasIndex(m => new { m.TenantId, m.CaseType });
            e.HasIndex(m => m.LeadAttorneyId);
        });

        modelBuilder.Entity<TimeEntry>(e =>
        {
            e.HasIndex(t => new { t.MatterId, t.EntryDate });
            e.HasIndex(t => new { t.UserId, t.EntryDate });
            e.HasIndex(t => new { t.InvoiceId });
        });

        modelBuilder.Entity<AuditLogEntry>(e =>
        {
            e.HasIndex(a => new { a.TenantId, a.Timestamp });
            e.HasIndex(a => new { a.EntityType, a.EntityId });
            e.HasIndex(a => a.MatterId);
        });

        modelBuilder.Entity<MatterDocument>(e =>
        {
            e.HasIndex(d => new { d.MatterId, d.UploadedAt });
        });
    }
}

5. High-Level Architecture

The architecture of a legal case management system follows a modular monolith or microservices pattern depending on the scale of the organization. For a platform serving multiple law firms, we recommend a microservices architecture with well-defined service boundaries. Each service owns its data and communicates with other services through async event-driven messaging where possible and synchronous REST or gRPC where real-time responses are required.

graph TB subgraph "Client Layer" A[Web App - React/Blazor] B[Mobile App - MAUI/Xamarin] C[API Clients - Third Party] end subgraph "API Gateway" D[Azure API Management / Kong] E[Rate Limiter] F[JWT Auth Validator] end subgraph "Application Services" G[Matter Service] H[Document Service] I[Time & Billing Service] J[Calendar Service] K[Search Service] L[Notification Service] M[Workflow Engine] N[Conflict Check Service] O[Client Portal Service] P[Reporting Service] end subgraph "Infrastructure Services" Q[PostgreSQL Cluster] R[Elasticsearch Cluster] S[Azure Blob Storage] T[Redis Cache] U[RabbitMQ / Azure Service Bus] V[Key Vault] end A & B & C --> D D --> E --> F F --> G & H & I & J & K & L & M & N & O & P G & I & J & N & O --> Q H --> S K --> R G & H & I & L & M --> U G & H & I --> T G & H & I & L --> V

Service Responsibilities

The Matter Service is the core domain service responsible for the complete lifecycle of legal matters. It handles creation, status transitions, party management, and matter metadata. It publishes domain events such as MatterCreated, MatterStatusChanged, and MatterClosed for other services to consume.

The Document Service manages the upload, storage, versioning, and retrieval of legal documents. It interfaces with Azure Blob Storage for binary content, maintains metadata in PostgreSQL, and sends extracted text to the Search Service for indexing. It enforces access control based on matter-level permissions and document classification levels.

The Time and Billing Service handles time tracking, expense recording, rate management, invoice generation, and payment processing. It integrates with LEDES format exporters for e-billing submission to corporate legal departments and supports trust account (IOLTA) management as required by bar association rules.

The Calendar Service manages court deadlines, hearing dates, statute of limitations tracking, and internal milestones. It calculates deadlines based on jurisdictional rules and sends reminder notifications through the Notification Service. It maintains its own calendar database optimized for date-range queries and recurrence patterns.

The Search Service provides full-text search across all case data and documents. It maintains an Elasticsearch index that is updated asynchronously when new documents are uploaded or case metadata changes. It supports complex queries with faceted filtering, date ranges, and relevance ranking tuned for legal terminology.

The Workflow Engine executes configurable business processes for matter intake, document review, discovery management, and case closure. It supports sequential and parallel task execution, conditional branching, SLA tracking, and automatic escalation for overdue tasks. It is built on a state machine pattern with persistent workflow instances.

The Conflict Check Service screens new matters against the firm's complete history of parties, opposing counsel, judges, and related entities. It uses fuzzy matching algorithms to account for name variations and maintains a graph of entity relationships to detect indirect conflicts such as when a party in a new matter is affiliated with a party from a previous adverse matter.

sequenceDiagram participant Attorney participant MatterSvc participant ConflictSvc participant WorkflowEngine participant NotifSvc participant DB Attorney->>MatterSvc: CreateNewMatter(request) MatterSvc->>ConflictSvc: ScreenConflict(parties) ConflictSvc->>DB: QueryHistoricalParties(parties) ConflictSvc-->>MatterSvc: ConflictResult(hasConflict=false) MatterSvc->>DB: SaveMatter(matter) MatterSvc->>WorkflowEngine: TriggerWorkflow(matter, intake) MatterSvc->>NotifSvc: NotifyPartners(matter) MatterSvc-->>Attorney: MatterCreated(matterId)

6. API Design

The API layer follows RESTful conventions with consistent resource naming, proper HTTP methods, and standardized error responses. We version the API explicitly through URL path prefixing to support backward compatibility as the platform evolves. All endpoints require JWT authentication with tenant-scoped access tokens.

Matter Endpoints

MethodEndpointDescription
POST/api/v1/mattersCreate a new legal matter
GET/api/v1/mattersList matters with filtering and pagination
GET/api/v1/matters/{id}Get matter details including parties and metadata
PATCH/api/v1/matters/{id}Update matter fields
POST/api/v1/matters/{id}/statusTransition matter to a new status
GET/api/v1/matters/{id}/documentsList documents for a matter
POST/api/v1/matters/{id}/documentsUpload a new document to a matter
GET/api/v1/matters/{id}/deadlinesList deadlines for a matter
GET/api/v1/matters/{id}/time-entriesList time entries for a matter
POST/api/v1/matters/{id}/time-entriesRecord a new time entry
GET/api/v1/matters/{id}/audit-logView audit trail for a matter

Document Endpoints

MethodEndpointDescription
POST/api/v1/documents/uploadUpload a document with multipart form data
GET/api/v1/documents/{id}Get document metadata and version history
GET/api/v1/documents/{id}/downloadDownload the latest version of a document
POST/api/v1/documents/{id}/versionsUpload a new version of an existing document
GET/api/v1/documents/{id}/versions/{version}Download a specific version of a document
DELETE/api/v1/documents/{id}Soft-delete a document (retains audit trail)

C# API Controller Implementation

C#
[ApiController]
[Route("api/v1/matters")]
[Authorize]
public class MattersController : ControllerBase
{
    private readonly IMatterService _matterService;
    private readonly IConflictCheckService _conflictService;
    private readonly IAuditLogger _auditLogger;

    public MattersController(
        IMatterService matterService,
        IConflictCheckService conflictService,
        IAuditLogger auditLogger)
    {
        _matterService = matterService;
        _conflictService = conflictService;
        _auditLogger = auditLogger;
    }

    [HttpPost]
    [Authorize(Roles = "Partner,Associate,Paralegal")]
    [ProducesResponseType(typeof(MatterResponse), StatusCodes.Status201Created)]
    [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status409Conflict)]
    public async Task<IActionResult> CreateMatter(
        [FromBody] CreateMatterRequest request)
    {
        var tenantId = GetTenantId();

        var conflictResult = await _conflictService.ScreenNewMatterAsync(
            tenantId, request.Parties);

        if (conflictResult.HasConflicts)
        {
            return Conflict(new ErrorResponse
            {
                Code = "CONFLICT_DETECTED",
                Message = "Potential conflicts of interest detected",
                Details = conflictResult.Conflicts.Select(c => new
                {
                    c.ExistingMatterId,
                    c.ExistingMatterNumber,
                    c.ConflictingParty,
                    c.ConflictType,
                    c.ConfidenceScore
                })
            });
        }

        var matter = await _matterService.CreateMatterAsync(tenantId, request);

        await _auditLogger.LogAsync(tenantId, matter.Id, GetUserId(),
            "MatterCreated", "Matter", matter.Id,
            null, new { matter.MatterNumber, matter.Title, matter.CaseType });

        return CreatedAtAction(
            nameof(GetMatter),
            new { id = matter.Id },
            MatterResponse.From(matter));
    }

    [HttpGet("{id:guid}")]
    [ProducesResponseType(typeof(MatterDetailResponse), StatusCodes.Status200OK)]
    public async Task<IActionResult> GetMatter(Guid id)
    {
        var tenantId = GetTenantId();
        var matter = await _matterService.GetMatterDetailAsync(tenantId, id);
        if (matter is null) return NotFound();
        return Ok(MatterDetailResponse.From(matter));
    }

    [HttpPost("{id:guid}/status")]
    [Authorize(Roles = "Partner,Associate")]
    public async Task<IActionResult> TransitionStatus(
        Guid id, [FromBody] TransitionStatusRequest request)
    {
        var tenantId = GetTenantId();
        var userId = GetUserId();
        var result = await _matterService.TransitionStatusAsync(
            tenantId, id, request.TargetStatus, userId, request.Notes);

        if (!result.IsSuccess)
        {
            return BadRequest(new ErrorResponse
            {
                Code = "INVALID_TRANSITION",
                Message = result.ErrorMessage
            });
        }
        return Ok(new { result.Matter.Status, result.Matter.UpdatedAt });
    }

    private Guid GetTenantId() =>
        Guid.Parse(User.FindFirstValue("tenant_id")!);
    private Guid GetUserId() =>
        Guid.Parse(User.FindFirstValue("sub")!);
}

public class CreateMatterRequest
{
    [Required] public string Title { get; set; } = string.Empty;
    [Required] public CaseType CaseType { get; set; }
    [Required] public string Jurisdiction { get; set; } = string.Empty;
    public Guid? ClientId { get; set; }
    public List<PartyRequest> Parties { get; set; } = new();
    public string? Description { get; set; }
    public Dictionary<string, string> CustomFields { get; set; } = new();
}

public class ErrorResponse
{
    public string Code { get; set; } = string.Empty;
    public string Message { get; set; } = string.Empty;
    public object? Details { get; set; }
}

Pagination and Filtering

All list endpoints support cursor-based pagination for efficient traversal of large result sets. Filtering is supported through query parameters with a consistent syntax. The following pattern is applied across all list endpoints:

C#
public class MatterListRequest
{
    public string? Cursor { get; set; }
    public int PageSize { get; set; } = 25;
    public MatterStatus? Status { get; set; }
    public CaseType? CaseType { get; set; }
    public string? Search { get; set; }
    public Guid? LeadAttorneyId { get; set; }
    public DateTime? OpenedAfter { get; set; }
    public DateTime? OpenedBefore { get; set; }
    public string SortBy { get; set; } = "UpdatedAt";
    public SortDirection SortDirection { get; set; } = SortDirection.Desc;
}

public class PaginatedResponse<T>
{
    public List<T> Items { get; set; } = new();
    public string? NextCursor { get; set; }
    public bool HasMore { get; set; }
    public int TotalCount { get; set; }
}

7. Case Lifecycle Management

The case lifecycle is the backbone of a legal case management system. Every legal matter progresses through a series of defined states from initial intake to final resolution. The specific states and transitions depend on the type of matter. A litigation case, for example, follows a different path than a transactional matter. The lifecycle must be flexible enough to accommodate these differences while providing enough structure to enforce consistent processes across the firm.

State Machine Design

stateDiagram-v2 [*] --> Intake Intake --> Opening : Conflict Check Passed Opening --> Active : Matter Opened Active --> OnHold : Temporarily Paused OnHold --> Active : Resumed Active --> Discovery : Discovery Phase Discovery --> Trial : Ready for Trial Trial --> Settlement : Settled Trial --> Closed : Verdict Received Settlement --> Closed : Settlement Finalized Active --> Settlement : Pre-Trial Settlement Active --> Closed : Resolved Discovery --> Closed : Dismissed Closed --> Archived : After Retention Period

C# Lifecycle Engine

C#
public class MatterLifecycleEngine
{
    private static readonly Dictionary<MatterStatus, HashSet<MatterStatus>>
        AllowedTransitions = new()
    {
        [MatterStatus.Intake] = new() { MatterStatus.Opening },
        [MatterStatus.Opening] = new() { MatterStatus.Active },
        [MatterStatus.Active] = new()
        {
            MatterStatus.OnHold,
            MatterStatus.Discovery,
            MatterStatus.Settlement,
            MatterStatus.Closed
        },
        [MatterStatus.OnHold] = new() { MatterStatus.Active },
        [MatterStatus.Discovery] = new()
        {
            MatterStatus.Trial,
            MatterStatus.Settlement,
            MatterStatus.Closed
        },
        [MatterStatus.Trial] = new()
        {
            MatterStatus.Settlement,
            MatterStatus.Closed
        },
        [MatterStatus.Settlement] = new() { MatterStatus.Closed },
        [MatterStatus.Closed] = new() { MatterStatus.Archived },
        [MatterStatus.Archived] = new() { MatterStatus.Active }
    };

    public static bool CanTransition(
        MatterStatus current, MatterStatus target)
    {
        return AllowedTransitions.TryGetValue(current, out var allowed)
            && allowed.Contains(target);
    }

    public async Task<TransitionResult> TransitionAsync(
        Matter matter, MatterStatus targetStatus,
        Guid userId, string? notes = null)
    {
        if (!CanTransition(matter.Status, targetStatus))
        {
            return TransitionResult.Fail(
                $"Cannot transition from {matter.Status} to {targetStatus}. " +
                $"Allowed transitions: {string.Join(", ",
                    AllowedTransitions[matter.Status])}");
        }

        var previousStatus = matter.Status;
        matter.Status = targetStatus;
        matter.UpdatedAt = DateTime.UtcNow;

        if (targetStatus == MatterStatus.Closed)
            matter.ClosedDate = DateTime.UtcNow;

        await PublishTransitionEvent(
            matter.Id, previousStatus, targetStatus, userId, notes);
        await TriggerStatusSpecificActions(
            matter, previousStatus, targetStatus);

        return TransitionResult.Success(matter);
    }

    private async Task TriggerStatusSpecificActions(
        Matter matter, MatterStatus from, MatterStatus to)
    {
        switch (to)
        {
            case MatterStatus.Intake:
                await TriggerConflictCheck(matter);
                await GenerateEngagementLetter(matter);
                break;
            case MatterStatus.Active:
                await SetupDeadlineCalendar(matter);
                await AssignTeamMembers(matter);
                await CreateInitialTaskList(matter);
                break;
            case MatterStatus.Discovery:
                await CreateDiscoveryChecklist(matter);
                await SetupDocumentReviewWorkflow(matter);
                await CalculateDiscoveryDeadlines(matter);
                break;
            case MatterStatus.Closed:
                await GenerateFinalInvoice(matter);
                await TriggerDocumentRetentionPolicy(matter);
                await SendClientClosureNotification(matter);
                await UpdateConflictDatabase(matter);
                break;
        }
    }
}

The lifecycle engine enforces valid transitions and triggers domain-specific actions at each transition point. For example, transitioning a matter to Active automatically sets up the deadline calendar based on jurisdictional rules, while transitioning to Closed generates the final invoice and archives documents according to the firm's retention policy. This automation reduces the risk of human error in critical processes and ensures that no step is missed during matter progression.

8. Document Management & Versioning

Document management is one of the most complex subsystems in a legal case management system. Legal professionals work with an enormous variety of document types including pleadings, motions, briefs, contracts, correspondence, discovery materials, depositions, expert reports, exhibits, and evidence. Each document may go through numerous revisions, must be tracked with full version history, and needs to be searchable through full-text indexing. Additionally, document access must be controlled at the matter level with classification-based restrictions.

Document Storage Architecture

graph LR A[Client Upload] --> B[API Gateway] B --> C[Document Service] C --> D[Content Hash Check] D -->|New| E[Blob Storage] D -->|Duplicate| F[Reference Existing] E --> G[Text Extraction] G --> H[Elasticsearch Index] C --> I[PostgreSQL Metadata] E --> J[CDN Distribution]

Version Control System

Our versioning system uses a content-addressable storage approach where each unique document version is stored as a separate blob identified by its SHA-256 hash. This eliminates duplicate storage when the same file is uploaded multiple times and provides cryptographic integrity verification. Each document record maintains a linked list of versions, with the latest version being the default for reads.

C#
public class DocumentService : IDocumentService
{
    private readonly IBlobStorage _blobStorage;
    private readonly LegalDbContext _dbContext;
    private readonly ITextExtractor _textExtractor;
    private readonly ISearchIndexer _searchIndexer;
    private readonly IAuditLogger _auditLogger;

    public async Task<MatterDocument> UploadDocumentAsync(
        Guid tenantId, Guid matterId, Guid userId,
        Stream fileStream, string fileName, string contentType,
        List<string> tags, DocumentClassification classification)
    {
        var contentHash = await ComputeContentHashAsync(fileStream);
        var existingDoc = await _dbContext.Documents
            .FirstOrDefaultAsync(d =>
                d.MatterId == matterId &&
                d.ContentHash == contentHash &&
                !d.IsDeleted);

        if (existingDoc is not null)
        {
            throw new DuplicateDocumentException(
                $"Document with identical content exists: {existingDoc.Id}");
        }

        var matterFolder = $"{tenantId}/{matterId}";
        var blobName = $"{matterFolder}/{Guid.NewGuid()}/{fileName}";

        fileStream.Position = 0;
        await _blobStorage.UploadAsync(blobName, fileStream, contentType);

        var document = new MatterDocument
        {
            MatterId = matterId,
            Title = Path.GetFileNameWithoutExtension(fileName),
            FileName = fileName,
            ContentType = contentType,
            FileSize = fileStream.Length,
            StoragePath = blobName,
            ContentHash = contentHash,
            UploadedById = userId,
            Tags = tags,
            Classification = classification,
            Versions = new List<DocumentVersion>
            {
                new()
                {
                    VersionNumber = 1,
                    StoragePath = blobName,
                    ChangeSummary = "Initial upload",
                    CreatedById = userId,
                    FileSize = fileStream.Length
                }
            }
        };

        _dbContext.Documents.Add(document);
        await _dbContext.SaveChangesAsync();

        _ = Task.Run(async () =>
        {
            var text = await _textExtractor.ExtractTextAsync(
                fileStream, contentType);
            await _searchIndexer.IndexDocumentAsync(document, text);
        });

        await _auditLogger.LogAsync(tenantId, matterId, userId,
            "DocumentUploaded", "Document", document.Id,
            null, new { document.FileName, document.FileSize,
                        document.Classification });

        return document;
    }

    public async Task<MatterDocument> UploadNewVersionAsync(
        Guid tenantId, Guid documentId, Guid userId,
        Stream fileStream, string changeSummary)
    {
        var document = await _dbContext.Documents
            .Include(d => d.Versions)
            .FirstAsync(d =>
                d.Id == documentId && d.TenantId == tenantId);

        var nextVersion = document.Versions.Max(v => v.VersionNumber) + 1;
        var blobName = $"{document.StoragePath}/v{nextVersion}";

        fileStream.Position = 0;
        await _blobStorage.UploadAsync(
            blobName, fileStream, document.ContentType);

        var version = new DocumentVersion
        {
            DocumentId = documentId,
            VersionNumber = nextVersion,
            StoragePath = blobName,
            ChangeSummary = changeSummary,
            CreatedById = userId,
            FileSize = fileStream.Length
        };

        document.Versions.Add(version);
        document.FileSize = fileStream.Length;
        document.ContentHash = await ComputeContentHashAsync(fileStream);
        document.UpdatedAt = DateTime.UtcNow;

        await _dbContext.SaveChangesAsync();

        _ = Task.Run(async () =>
        {
            var text = await _textExtractor.ExtractTextAsync(
                fileStream, document.ContentType);
            await _searchIndexer.UpdateDocumentAsync(document, text);
        });

        await _auditLogger.LogAsync(tenantId, document.MatterId, userId,
            "DocumentVersionUploaded", "Document", documentId,
            new { Version = nextVersion - 1 },
            new { Version = nextVersion, changeSummary });

        return document;
    }

    private async Task<string> ComputeContentHashAsync(Stream stream)
    {
        stream.Position = 0;
        using var sha256 = SHA256.Create();
        var hash = await sha256.ComputeHashAsync(stream);
        stream.Position = 0;
        return Convert.ToBase64String(hash);
    }
}

Text Extraction Pipeline

When a document is uploaded, the system asynchronously extracts its text content for full-text indexing. For PDF documents, we use iTextSharp or PDFsharp to extract embedded text. For scanned PDFs and image files, we invoke Azure Cognitive Services or Tesseract OCR to perform optical character recognition. Microsoft Word documents are processed using the Open XML SDK. The extracted text is then sent to Elasticsearch for indexing with the document ID as the foreign key. This pipeline runs asynchronously to avoid blocking the upload response and includes retry logic for transient failures in the OCR service.

C#
public class TextExtractionOrchestrator : ITextExtractor
{
    private readonly IPdfTextExtractor _pdfExtractor;
    private readonly IWordTextExtractor _wordExtractor;
    private readonly IOcrService _ocrService;

    public async Task<string> ExtractTextAsync(
        Stream fileStream, string contentType)
    {
        fileStream.Position = 0;

        return contentType.ToLowerInvariant() switch
        {
            "application/pdf" =>
                await ExtractPdfTextAsync(fileStream),
            "application/vnd.openxmlformats-officedocument" +
                ".wordprocessingml.document" =>
                await _wordExtractor.ExtractAsync(fileStream),
            "image/tiff" or "image/png" or "image/jpeg" =>
                await _ocrService.RecognizeTextAsync(fileStream),
            "text/plain" =>
                await new StreamReader(fileStream).ReadToEndAsync(),
            _ => string.Empty
        };
    }

    private async Task<string> ExtractPdfTextAsync(Stream stream)
    {
        var text = await _pdfExtractor.ExtractTextAsync(stream);
        if (string.IsNullOrWhiteSpace(text) || text.Length < 50)
        {
            stream.Position = 0;
            return await _ocrService.RecognizeTextAsync(stream);
        }
        return text;
    }
}

9. Deadline & Court Date Tracking

Deadline tracking is arguably the most critical function of a legal case management system. A missed court deadline can result in case dismissal, sanctions, malpractice claims, and disbarment. The system must calculate deadlines based on jurisdictional rules, account for holidays and court closures, send multiple reminders through multiple channels, and provide an auditable trail of all deadline-related communications. Unlike generic calendar applications, legal deadline calculators must understand the complex rules that govern filing deadlines in different courts.

Jurisdictional Rule Engine

Different jurisdictions have different rules for calculating deadlines. For example, under Federal Rule of Civil Procedure 12(a), a defendant must respond to a complaint within 21 days of service. State courts may have different periods. Some jurisdictions exclude weekends and holidays from the count while others use calendar days. The deadline engine must model these rules precisely.

C#
public interface IDeadlineCalculator
{
    Task<List<CourtDeadline>> CalculateDeadlinesAsync(
        Matter matter, DateTime triggerDate);
}

public class FederalDeadlineCalculator : IDeadlineCalculator
{
    private readonly IHolidayCalendar _holidayCalendar;

    public FederalDeadlineCalculator(IHolidayCalendar holidayCalendar)
    {
        _holidayCalendar = holidayCalendar;
    }

    public async Task<List<CourtDeadline>> CalculateDeadlinesAsync(
        Matter matter, DateTime triggerDate)
    {
        var deadlines = new List<CourtDeadline>();

        switch (matter.CaseType)
        {
            case CaseType.Litigation:
                deadlines.AddRange(
                    await CalculateLitigationDeadlines(
                        matter, triggerDate));
                break;
            case CaseType.CriminalDefense:
                deadlines.AddRange(
                    await CalculateCriminalDeadlines(
                        matter, triggerDate));
                break;
        }

        return deadlines;
    }

    private async Task<List<CourtDeadline>> CalculateLitigationDeadlines(
        Matter matter, DateTime triggerDate)
    {
        var deadlines = new List<CourtDeadline>();

        deadlines.Add(new CourtDeadline
        {
            MatterId = matter.Id,
            Description = "Defendant Response (FRCP 12(a))",
            DueDate = AddBusinessDays(triggerDate, 21),
            RuleBasis = "FRCP 12(a)(1)(A)",
            IsFilingDeadline = true,
            Reminders = new List<DeadlineReminder>
            {
                new() { DaysBefore = 14, Channel = NotificationChannel.Email },
                new() { DaysBefore = 7, Channel = NotificationChannel.Email },
                new() { DaysBefore = 3, Channel = NotificationChannel.Email },
                new() { DaysBefore = 1, Channel = NotificationChannel.SMS },
                new() { DaysBefore = 0, Channel = NotificationChannel.SMS }
            }
        });

        deadlines.Add(new CourtDeadline
        {
            MatterId = matter.Id,
            Description = "Initial Disclosures (FRCP 26(a)(1))",
            DueDate = AddBusinessDays(triggerDate, 120),
            RuleBasis = "FRCP 26(a)(1)",
            IsFilingDeadline = true
        });

        deadlines.Add(new CourtDeadline
        {
            MatterId = matter.Id,
            Description = "Expert Reports Due (FRCP 26(a)(2)(B))",
            DueDate = AddBusinessDays(triggerDate, 270),
            RuleBasis = "FRCP 26(a)(2)(B)",
            IsFilingDeadline = true
        });

        return deadlines;
    }

    private DateTime AddBusinessDays(
        DateTime startDate, int businessDays)
    {
        var currentDate = startDate;
        var remainingDays = businessDays;

        while (remainingDays > 0)
        {
            currentDate = currentDate.AddDays(1);
            if (currentDate.DayOfWeek is not
                (DayOfWeek.Saturday or DayOfWeek.Sunday) &&
                !_holidayCalendar.IsHoliday(currentDate))
            {
                remainingDays--;
            }
        }

        return currentDate;
    }
}

Deadline Monitoring Dashboard

Deadline CategoryAlert ThresholdsChannelsEscalation
Court Filing30, 14, 7, 3, 1, 0 daysEmail, SMS, Push, In-AppPartner notification at 7 days
Discovery Response21, 14, 7, 3, 1 daysEmail, SMS, Push, In-AppManaging Partner at 3 days
Statute of Limitations180, 90, 60, 30, 14, 7 daysEmail, SMS, In-AppFirm-wide alert at 30 days
Internal Milestone7, 3, 1 daysEmail, In-AppSupervisor at 3 days
Client Deadline14, 7, 3 daysEmail, In-AppAttorney at 7 days

10. Time Billing & Invoicing

Time billing is the revenue engine of every law firm. Attorneys track their time in increments as small as six minutes (0.1 hours) and the system must aggregate these entries into invoices that comply with LEDES (Legal Electronic Data Exchange Standard) and UTBMS (Uniform Task-Based Management System) formats required by corporate legal departments. Trust account (IOLTA) management adds further complexity, as client funds held in trust must be carefully tracked and reconciled to avoid commingling violations.

LEDES Format Support

LEDES 1998B is the most widely used format for electronic billing in the legal industry. Each invoice line item must include the date, timekeeper ID, hours, rate, task code, activity code, and description. The system must generate LEDES files that are compatible with e-billing platforms such as CounselLink, Legal Tracker, and Collaborati.

C#
public class LedesInvoiceGenerator
{
    private const string LEDES_HEADER =
        "INVOICE_DATE|INVOICE_NUMBER|CLIENT_ID|CLIENT_MATTER_ID|" +
        "INVOICE_TOTAL|BILLING_START_DATE|BILLING_END_DATE|" +
        "INVOICE_DESCRIPTION";

    private const string LEDES_LINE_HEADER =
        "TIME_ENTRY_DATE|TIMEKEEPER_ID|TIMEKEEPER_NAME|" +
        "TIMEKEEPER_CLASSIFICATION|TASK_CODE|ACTIVITY_CODE|" +
        "TIME_ENTRY_DESCRIPTION|TIME_ENTRY_HOURS|" +
        "TIME_ENTRY_RATE|TIME_ENTRY_TOTAL";

    public string GenerateLedesFile(Invoice invoice)
    {
        var sb = new StringBuilder();
        sb.AppendLine(LEDES_HEADER);
        sb.AppendLine(string.Join("|",
            invoice.IssuedAt?.ToString("yyyyMMdd") ?? "",
            invoice.InvoiceNumber,
            invoice.ClientMatterNumber,
            invoice.ClientMatterId,
            invoice.TotalAmount.ToString("F2"),
            invoice.PeriodStart.ToString("yyyyMMdd"),
            invoice.PeriodEnd.ToString("yyyyMMdd"),
            $"Legal services - {invoice.MatterTitle}"));

        sb.AppendLine();
        sb.AppendLine(LEDES_LINE_HEADER);

        foreach (var entry in invoice.TimeEntries)
        {
            sb.AppendLine(string.Join("|",
                entry.EntryDate.ToString("yyyyMMdd"),
                entry.TimekeeperId,
                entry.TimekeeperName,
                entry.TimekeeperClassification,
                entry.TaskCode,
                entry.ActivityCode,
                SanitizeDescription(entry.Description),
                entry.Hours.ToString("F2"),
                entry.Rate.ToString("F2"),
                (entry.Hours * entry.Rate).ToString("F2")));
        }

        foreach (var expense in invoice.Expenses)
        {
            sb.AppendLine(string.Join("|",
                expense.Date.ToString("yyyyMMdd"),
                expense.TimekeeperId,
                expense.TimekeeperName,
                expense.TimekeeperClassification,
                expense.ExpenseCode,
                "EX",
                SanitizeDescription(expense.Description),
                "1.00",
                expense.Amount.ToString("F2"),
                expense.Amount.ToString("F2")));
        }

        return sb.ToString();
    }
}

public class TrustAccountManager
{
    private readonly LegalDbContext _dbContext;

    public async Task<TrustTransaction> RecordTrustDepositAsync(
        Guid matterId, decimal amount, string description)
    {
        var trustAccount = await _dbContext.TrustAccounts
            .FirstAsync(t => t.MatterId == matterId);

        trustAccount.Balance += amount;

        var transaction = new TrustTransaction
        {
            TrustAccountId = trustAccount.Id,
            Amount = amount,
            Type = TrustTransactionType.Deposit,
            Description = description,
            RunningBalance = trustAccount.Balance
        };

        _dbContext.TrustTransactions.Add(transaction);
        await _dbContext.SaveChangesAsync();
        return transaction;
    }

    public async Task<TrustTransaction> RecordTrustDisbursementAsync(
        Guid matterId, decimal amount, string description,
        Guid invoiceId)
    {
        var trustAccount = await _dbContext.TrustAccounts
            .FirstAsync(t => t.MatterId == matterId);

        if (trustAccount.Balance < amount)
        {
            throw new InsufficientTrustFundsException(
                $"Trust balance {trustAccount.Balance:C} " +
                $"insufficient for disbursement {amount:C}");
        }

        trustAccount.Balance -= amount;

        var transaction = new TrustTransaction
        {
            TrustAccountId = trustAccount.Id,
            Amount = -amount,
            Type = TrustTransactionType.Disbursement,
            Description = description,
            RelatedInvoiceId = invoiceId,
            RunningBalance = trustAccount.Balance
        };

        _dbContext.TrustTransactions.Add(transaction);
        await _dbContext.SaveChangesAsync();
        return transaction;
    }
}

UTBMS Task and Activity Codes

Code CategoryExamplesDescription
L100 - Case AssessmentL110, L120, L130Development and evaluation of factual findings, strategy, and legal research
L200 - Pre-Trial PleadingsL210, L220, L230Complaints, answers, motions, and related documents
L300 - DiscoveryL310, L320, L330Written discovery, document production, and depositions
L400 - Trial PreparationL410, L420, L430Witness preparation, exhibit organization, and motion practice
L500 - TrialL510, L520, L530Jury selection, opening statements, examination, and closing
L600 - AppealL610, L620, L630Appellate briefs, oral argument preparation, and record review

11. Client Portal

A modern client portal transforms the attorney-client relationship by providing transparency, reducing communication friction, and building trust. Clients expect to view case status, access shared documents, review invoices, and communicate with their legal team through a secure, branded web interface. The portal must maintain strict access controls to ensure that clients can only see information related to their own matters and that privileged communications are appropriately restricted.

Portal Architecture

graph TB subgraph "Client Portal" A[Client Login - SSO/OAuth] B[Dashboard] C[Case Status] D[Document Center] E[Invoice Review] F[Secure Messaging] G[Timeline View] end subgraph "Access Control Layer" H[Client Permission Service] I[Matter-Level ACL] J[Document Classification Filter] end subgraph "Backend Services" K[Matter Service] L[Document Service] M[Billing Service] N[Notification Service] O[Messaging Service] end A --> H H --> B & C & D & E & F & G B --> K C --> K D --> L E --> M F --> O G --> K I --> K & L J --> L

Permission Model for Client Access

C#
public class ClientPortalPermissionService
{
    private readonly LegalDbContext _dbContext;

    public async Task<ClientPortalAccess> GetClientAccessAsync(
        Guid clientId, Guid matterId)
    {
        var matter = await _dbContext.Matters
            .Include(m => m.Documents)
            .Include(m => m.Invoices)
            .FirstOrDefaultAsync(m => m.Id == matterId);

        if (matter is null || matter.ClientId != clientId)
        {
            throw new UnauthorizedAccessException(
                "Client does not have access to this matter");
        }

        return new ClientPortalAccess
        {
            MatterId = matterId,
            CanViewStatus = true,
            CanViewTimeline = true,
            CanAccessDocuments = matter.Documents
                .Where(d => d.Classification !=
                    DocumentClassification.Privileged)
                .Select(d => new DocumentAccess
                {
                    DocumentId = d.Id,
                    Title = d.Title,
                    FileName = d.FileName,
                    CanDownload = d.Classification !=
                        DocumentClassification.WorkProduct,
                    UploadedAt = d.UploadedAt
                }).ToList(),
            CanViewInvoices = matter.Invoices
                .Where(i => i.Status != InvoiceStatus.Draft)
                .Select(i => new InvoiceAccess
                {
                    InvoiceId = i.Id,
                    InvoiceNumber = i.InvoiceNumber,
                    TotalAmount = i.TotalAmount,
                    Status = i.Status,
                    PeriodStart = i.PeriodStart,
                    PeriodEnd = i.PeriodEnd,
                    CanPay = i.Status == InvoiceStatus.Issued ||
                             i.Status == InvoiceStatus.Sent
                }).ToList(),
            CanSendMessages = true,
            MatterStatus = matter.Status.ToString(),
            LastUpdated = matter.UpdatedAt
        };
    }
}

The portal also provides a visual timeline view that shows the progression of the matter through its lifecycle, upcoming deadlines, recently uploaded documents, and billing milestones. This timeline view is particularly valuable for clients who want to understand where their matter stands without requiring a detailed explanation from their attorney. The portal is built as a responsive single-page application that works seamlessly on desktop and mobile browsers.

12. Search & Discovery

Search functionality in a legal case management system must go far beyond simple keyword matching. Attorneys and paralegals need to find specific clauses across hundreds of contracts, locate all communications related to a particular issue, identify documents mentioning a specific person or entity, and perform complex multi-criteria searches that combine full-text queries with metadata filters. The search engine must understand legal terminology, support Boolean operators, handle document synonyms, and provide relevance ranking that prioritizes the most important matches.

Elasticsearch Index Design

JSON
{
  "mappings": {
    "properties": {
      "document_id": { "type": "keyword" },
      "matter_id": { "type": "keyword" },
      "tenant_id": { "type": "keyword" },
      "title": {
        "type": "text",
        "analyzer": "legal_english",
        "fields": {
          "keyword": { "type": "keyword" }
        }
      },
      "content": {
        "type": "text",
        "analyzer": "legal_english"
      },
      "file_name": { "type": "keyword" },
      "content_type": { "type": "keyword" },
      "classification": { "type": "keyword" },
      "tags": { "type": "keyword" },
      "uploaded_by": { "type": "keyword" },
      "uploaded_at": { "type": "date" },
      "file_size": { "type": "long" },
      "version": { "type": "integer" },
      "parties": {
        "type": "nested",
        "properties": {
          "name": { "type": "text" },
          "role": { "type": "keyword" },
          "type": { "type": "keyword" }
        }
      }
    }
  },
  "settings": {
    "analysis": {
      "analyzer": {
        "legal_english": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": [
            "lowercase",
            "legal_synonyms",
            "english_stemmer"
          ]
        }
      },
      "filter": {
        "legal_synonyms": {
          "type": "synonym",
          "synonyms": [
            "plaintiff,claimant,petitioner",
            "defendant,respondent",
            "attorney,counsel,lawyer",
            "deposition,examination",
            "motion,application",
            "stipulation,agreement"
          ]
        }
      }
    }
  }
}

Search API

C#
public class DocumentSearchService : IDocumentSearchService
{
    private readonly IElasticClient _elasticClient;

    public async Task<SearchResult> SearchDocumentsAsync(
        Guid tenantId, DocumentSearchRequest request)
    {
        var searchDescriptor = new SearchDescriptor<DocumentIndex>()
            .Index("legal-documents")
            .Size(request.PageSize)
            .From(request.Offset)
            .Query(q =>
                q.Bool(b =>
                    b.Filter(f =>
                        f.Term(t =>
                            t.Field(d => d.TenantId)
                             .Value(tenantId)))
                     .Must(m =>
                     {
                         if (!string.IsNullOrWhiteSpace(request.Query))
                         {
                             return m.MultiMatch(mm =>
                                 mm.Fields(ff =>
                                     ff.Field(d => d.Title, 2.0)
                                       .Field(d => d.Content)
                                       .Field(d => d.FileName))
                                  .Query(request.Query)
                                  .Type(TextQueryType.BestFields)
                                  .Fuzziness(Fuzziness.Auto));
                         }
                         return m.MatchAll();
                     })
                     .Filter(BuildFacetFilters(request))));

        var response = await _elasticClient
            .SearchAsync<DocumentIndex>(searchDescriptor);

        return new SearchResult
        {
            TotalCount = (int)response.Total,
            Items = response.Documents.Select(d =>
                new SearchResultItem
                {
                    DocumentId = d.DocumentId,
                    MatterId = d.MatterId,
                    Title = d.Title,
                    FileName = d.FileName,
                    Snippet = GetSnippet(d.Content, request.Query),
                    Score = d.Score,
                    UploadedAt = d.UploadedAt,
                    Classification = d.Classification
                }).ToList(),
            Facets = ExtractFacets(response)
        };
    }
}

Search Features Matrix

FeatureDescriptionImplementation
Full-Text SearchSearch across document content and metadataElasticsearch with custom legal analyzer
Boolean OperatorsAND, OR, NOT with groupingElasticsearch Query DSL
Phrase SearchExact phrase matching with quotesMatch Phrase Query
Fuzzy SearchTolerant of typos and misspellingsFuzziness parameter with edit distance
Faceted FilteringFilter by type, date, author, classificationElasticsearch Aggregations
Date Range SearchFind documents within date rangesRange Query on uploaded_at
Party SearchFind documents mentioning specific partiesNested Query on parties field
Result HighlightingHighlight matching terms in resultsHighlight API with fragment size

13. Workflow Automation

Workflow automation transforms a legal case management system from a passive record-keeping tool into an active process enforcer. Many legal processes follow predictable patterns. New matter intake involves a specific sequence of steps: conflict check, engagement letter, initial team assignment, and matter setup. Discovery management involves document requests, responses, review, and production on a defined timeline. By encoding these processes as configurable workflows, we ensure consistency, reduce administrative overhead, and prevent steps from being accidentally skipped.

Workflow Definition Model

C#
public class WorkflowDefinition
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string Name { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public CaseType? ApplicableCaseType { get; set; }
    public List<WorkflowStep> Steps { get; set; } = new();
    public List<WorkflowTransition> Transitions { get; set; } = new();
    public bool IsActive { get; set; } = true;
}

public class WorkflowStep
{
    public string Id { get; set; } = string.Empty;
    public string Name { get; set; } = string.Empty;
    public WorkflowStepType Type { get; set; }
    public string? AssigneeRole { get; set; }
    public int? SLADays { get; set; }
    public List<WorkflowAction> Actions { get; set; } = new();
    public bool RequiresApproval { get; set; }
    public string? ApprovalRole { get; set; }
}

public enum WorkflowStepType
{
    Manual, Automated, Approval, Parallel,
    SubProcess, Timer, External
}

public class WorkflowAction
{
    public WorkflowActionType Type { get; set; }
    public string? TargetService { get; set; }
    public string? TemplateId { get; set; }
    public Dictionary<string, string> Parameters { get; set; } = new();
}

public enum WorkflowActionType
{
    SendEmail, CreateTask, GenerateDocument,
    UpdateMatterStatus, CallExternalApi,
    RunConflictCheck, CalculateDeadline,
    AssignUser, CreateCalendarEvent
}

Workflow Engine Executor

C#
public class WorkflowEngine
{
    private readonly LegalDbContext _dbContext;
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<WorkflowEngine> _logger;

    public async Task<WorkflowInstance> StartWorkflowAsync(
        Guid workflowDefinitionId, Guid matterId,
        Dictionary<string, object> context)
    {
        var definition = await _dbContext.WorkflowDefinitions
            .Include(w => w.Steps)
            .Include(w => w.Transitions)
            .FirstAsync(w => w.Id == workflowDefinitionId);

        var instance = new WorkflowInstance
        {
            DefinitionId = workflowDefinitionId,
            MatterId = matterId,
            Status = WorkflowStatus.Running,
            CurrentStepId = definition.Steps.First().Id,
            Context = context,
            StartedAt = DateTime.UtcNow
        };

        _dbContext.WorkflowInstances.Add(instance);
        await _dbContext.SaveChangesAsync();
        await ExecuteCurrentStepAsync(instance, definition);

        return instance;
    }

    public async Task CompleteStepAsync(
        Guid instanceId, Guid completedBy,
        Dictionary<string, object> results)
    {
        var instance = await _dbContext.WorkflowInstances
            .FirstAsync(w => w.Id == instanceId);

        var definition = await _dbContext.WorkflowDefinitions
            .Include(w => w.Steps)
            .Include(w => w.Transitions)
            .FirstAsync(w => w.Id == instance.DefinitionId);

        instance.StepResults[instance.CurrentStepId] = results;
        instance.CompletedSteps.Add(new StepCompletion
        {
            StepId = instance.CurrentStepId,
            CompletedBy = completedBy,
            CompletedAt = DateTime.UtcNow,
            Results = results
        });

        var nextTransition = definition.Transitions
            .FirstOrDefault(t =>
                t.FromStepId == instance.CurrentStepId &&
                EvaluateCondition(t.Condition, instance));

        if (nextTransition is null)
        {
            instance.Status = WorkflowStatus.Completed;
            instance.CompletedAt = DateTime.UtcNow;
            _logger.LogInformation(
                "Workflow {Instance} completed for matter {Matter}",
                instanceId, instance.MatterId);
        }
        else
        {
            instance.CurrentStepId = nextTransition.ToStepId;
            await ExecuteCurrentStepAsync(instance, definition);
        }

        await _dbContext.SaveChangesAsync();
    }

    private async Task ExecuteCurrentStepAsync(
        WorkflowInstance instance,
        WorkflowDefinition definition)
    {
        var step = definition.Steps
            .First(s => s.Id == instance.CurrentStepId);

        foreach (var action in step.Actions)
        {
            var handler = _serviceProvider
                .GetRequiredService<IWorkflowActionHandler>(
                    action.Type.ToString());
            await handler.ExecuteAsync(instance, action);
        }

        if (step.SLADays.HasValue)
        {
            instance.StepDeadlines[step.Id] =
                DateTime.UtcNow.AddDays(step.SLADays.Value);
        }
    }
}

14. Conflict of Interest Check

Conflict of interest screening is a mandatory ethical obligation for all law firms. Before accepting a new matter, the firm must screen the proposed parties, opposing counsel, judges, and related entities against its entire history of past and current representations. A conflict of interest, if undetected, can result in case disqualification, malpractice liability, and bar association disciplinary action. The screening process must be thorough, fast, and auditable.

Conflict Detection Algorithm

C#
public class ConflictCheckService : IConflictCheckService
{
    private readonly LegalDbContext _dbContext;
    private readonly IFuzzyMatcher _fuzzyMatcher;
    private readonly IPartyGraphAnalyzer _graphAnalyzer;

    public async Task<ConflictCheckResult> ScreenNewMatterAsync(
        Guid tenantId, List<PartyRequest> proposedParties)
    {
        var historicalParties = await _dbContext.MatterParties
            .Include(p => p.Matter)
            .Where(p => p.Matter.TenantId == tenantId)
            .ToListAsync();

        var conflicts = new List<ConflictResult>();

        foreach (var proposed in proposedParties)
        {
            var exactMatches = historicalParties
                .Where(h => string.Equals(
                    h.PartyName.Trim(),
                    proposed.PartyName.Trim(),
                    StringComparison.OrdinalIgnoreCase))
                .ToList();

            foreach (var match in exactMatches)
            {
                conflicts.Add(new ConflictResult
                {
                    ProposedParty = proposed.PartyName,
                    ExistingMatterId = match.MatterId,
                    ExistingMatterNumber = match.Matter.MatterNumber,
                    ExistingParty = match.PartyName,
                    ConflictType = DetermineConflictType(
                        proposed.Role, match.Role),
                    ConfidenceScore = 1.0,
                    MatchType = MatchType.Exact
                });
            }

            var fuzzyMatches = historicalParties
                .Where(h => !exactMatches.Contains(h) &&
                    _fuzzyMatcher.Similarity(
                        h.PartyName, proposed.PartyName) > 0.85)
                .ToList();

            foreach (var match in fuzzyMatches)
            {
                var score = _fuzzyMatcher.Similarity(
                    match.PartyName, proposed.PartyName);
                conflicts.Add(new ConflictResult
                {
                    ProposedParty = proposed.PartyName,
                    ExistingMatterId = match.MatterId,
                    ExistingMatterNumber = match.Matter.MatterNumber,
                    ExistingParty = match.PartyName,
                    ConflictType = DetermineConflictType(
                        proposed.Role, match.Role),
                    ConfidenceScore = score,
                    MatchType = MatchType.Fuzzy
                });
            }
        }

        var indirectConflicts = await _graphAnalyzer
            .FindIndirectConflictsAsync(tenantId, proposedParties);
        conflicts.AddRange(indirectConflicts);

        var uniqueConflicts = conflicts
            .GroupBy(c => c.ExistingMatterId)
            .Select(g =>
                g.OrderByDescending(c => c.ConfidenceScore).First())
            .ToList();

        return new ConflictCheckResult
        {
            HasConflicts = uniqueConflicts.Any(),
            Conflicts = uniqueConflicts,
            ScreenedAt = DateTime.UtcNow,
            TotalPartiesScreened = proposedParties.Count,
            TotalHistoricalPartiesCompared = historicalParties.Count
        };
    }

    private ConflictType DetermineConflictType(
        string proposedRole, string existingRole)
    {
        if (proposedRole == "Defendant" &&
            existingRole == "Plaintiff" ||
            proposedRole == "Plaintiff" &&
            existingRole == "Defendant")
            return ConflictType.AdverseParty;

        if (proposedRole == existingRole)
            return ConflictType.SameSide;

        return ConflictType.RelatedParty;
    }
}

public class PartyGraphAnalyzer : IPartyGraphAnalyzer
{
    private readonly LegalDbContext _dbContext;

    public async Task<List<ConflictResult>>
        FindIndirectConflictsAsync(
        Guid tenantId, List<PartyRequest> proposedParties)
    {
        var allParties = await _dbContext.MatterParties
            .Include(p => p.Matter)
            .Where(p => p.Matter.TenantId == tenantId)
            .ToListAsync();

        var entityGraph =
            BuildEntityRelationshipGraph(allParties);
        var indirectConflicts = new List<ConflictResult>();

        foreach (var proposed in proposedParties)
        {
            var normalizedProposed = proposed.PartyName
                .ToLower().Trim();

            if (entityGraph.TryGetValue(normalizedProposed,
                out var relatedEntities))
            {
                foreach (var related in relatedEntities)
                {
                    if (related.IsAdverseToMatter)
                    {
                        indirectConflicts.Add(new ConflictResult
                        {
                            ProposedParty = proposed.PartyName,
                            ExistingMatterId = related.MatterId,
                            ExistingMatterNumber =
                                related.MatterNumber,
                            ExistingParty = related.PartyName,
                            ConflictType =
                                ConflictType.IndirectAdverse,
                            ConfidenceScore = 0.8,
                            MatchType =
                                MatchType.GraphTraversal,
                            RelationshipPath =
                                $"Related through {related.RelationshipType}"
                        });
                    }
                }
            }
        }

        return indirectConflicts;
    }
}

15. Multi-Tenant Law Firm Support

Multi-tenancy enables a single deployment of the legal case management platform to serve multiple law firms or legal departments, each with complete data isolation, configurable branding, and tenant-specific business rules. This is essential for both SaaS business models and for large law firms with multiple offices that need separate but integrated environments. The multi-tenant architecture must ensure that no tenant can ever access another tenant's data, even in the presence of bugs or misconfigurations.

Tenant Isolation Strategy

graph TB subgraph "Shared Infrastructure" A[API Gateway - Tenant Resolver] B[Application Services] end subgraph "Database Layer" C[(PostgreSQL - Shared Schema)] D[Elasticsearch - Index Per Tenant] E[Redis - Key Prefix Per Tenant] end subgraph "Storage Layer" F[(Blob Storage - Container Per Tenant)] end A -->|X-Tenant-ID Header| B B -->|WHERE tenant_id = @tenant| C B -->|Index = tenant-{id}| D B -->|Key = {tenant}:...| E B -->|Container = {tenant}-docs| F
C#
public class TenantResolver : ITenantResolver
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly ITenantService _tenantService;

    public TenantContext Resolve()
    {
        var httpContext = _httpContextAccessor.HttpContext
            ?? throw new InvalidOperationException(
                "No HTTP context available");

        var tenantId = httpContext.Request
            .Headers["X-Tenant-ID"].FirstOrDefault();

        if (string.IsNullOrEmpty(tenantId))
        {
            tenantId = httpContext.User
                .FindFirst("tenant_id")?.Value;
        }

        if (string.IsNullOrEmpty(tenantId) ||
            !Guid.TryParse(tenantId, out var parsedTenantId))
        {
            throw new TenantResolutionException(
                "Unable to resolve tenant from request");
        }

        var tenant = _tenantService.GetTenant(parsedTenantId)
            ?? throw new TenantResolutionException(
                $"Tenant {parsedTenantId} not found");

        return new TenantContext
        {
            TenantId = parsedTenantId,
            TenantName = tenant.Name,
            Settings = tenant.Settings
        };
    }
}

public class TenantAwareDbContext : DbContext
{
    private readonly ITenantResolver _tenantResolver;

    protected override void OnModelCreating(
        ModelBuilder modelBuilder)
    {
        var tenantId = _tenantResolver.Resolve().TenantId;

        foreach (var entityType in modelBuilder.Model
            .GetEntityTypes())
        {
            if (typeof(ITenantEntity).IsAssignableFrom(
                entityType.ClrType))
            {
                modelBuilder.Entity(entityType.ClrType)
                    .HasQueryFilter(CreateTenantFilter(
                        entityType.ClrType, tenantId));
            }
        }
    }

    private LambdaExpression CreateTenantFilter(
        Type entityType, Guid tenantId)
    {
        var parameter = Expression.Parameter(
            entityType, "e");
        var property = Expression.Property(
            parameter, "TenantId");
        var constant = Expression.Constant(tenantId);
        var equality = Expression.Equal(
            property, constant);
        return Expression.Lambda(equality, parameter);
    }
}

Tenant Configuration

Each tenant can customize their environment through a tenant settings profile that controls branding (logo, color scheme, firm name), billing configuration (default rates, billing format, currency), jurisdictional defaults (statute of limitations periods, court rules), notification preferences (email templates, reminder schedules), and access control policies (IP restrictions, two-factor requirements). These settings are cached in Redis with a five-minute TTL and are invalidated whenever the tenant administrator updates them.

16. Audit Trail & Compliance

A comprehensive, immutable audit trail is non-negotiable in a legal case management system. Courts, bar associations, and regulatory bodies may require proof of when actions were taken, who performed them, and what changed. The audit trail must capture every create, read, update, and delete operation on all entities, along with the user identity, timestamp, IP address, and before/after values for modifications. The audit log itself must be append-only and stored in a manner that prevents tampering even by system administrators.

Immutable Audit Log Design

C#
public class AuditLogger : IAuditLogger
{
    private readonly LegalDbContext _dbContext;
    private readonly IHttpContextAccessor _httpContextAccessor;

    public async Task LogAsync(
        Guid tenantId, Guid? matterId, Guid userId,
        string action, string entityType, Guid entityId,
        object? oldValues, object? newValues)
    {
        var entry = new AuditLogEntry
        {
            TenantId = tenantId,
            MatterId = matterId,
            UserId = userId,
            Action = action,
            EntityType = entityType,
            EntityId = entityId,
            OldValues = oldValues is not null
                ? JsonSerializer.Serialize(oldValues)
                : null,
            NewValues = newValues is not null
                ? JsonSerializer.Serialize(newValues)
                : null,
            IpAddress = _httpContextAccessor.HttpContext?
                .Connection?.RemoteIpAddress?.ToString()
                ?? "unknown",
            UserAgent = _httpContextAccessor.HttpContext?
                .Request?.Headers["User-Agent"]
                .FirstOrDefault() ?? "unknown",
            Timestamp = DateTime.UtcNow
        };

        entry.EntryHash = ComputeEntryHash(entry);

        await _dbContext.AuditLogs.AddAsync(entry);
        await _dbContext.SaveChangesAsync();

        await PublishAuditEventAsync(entry);
    }

    private string ComputeEntryHash(AuditLogEntry entry)
    {
        var data = $"{entry.TenantId}|{entry.UserId}|" +
            $"{entry.Action}|{entry.EntityType}|" +
            $"{entry.EntityId}|{entry.Timestamp:O}|" +
            $"{entry.OldValues}|{entry.NewValues}";

        using var sha256 = SHA256.Create();
        var hash = sha256.ComputeHash(
            Encoding.UTF8.GetBytes(data));
        return Convert.ToBase64String(hash);
    }
}

public class AuditTrailController : ControllerBase
{
    private readonly IAuditService _auditService;

    [HttpGet("api/v1/matters/{matterId}/audit-log")]
    public async Task<IActionResult> GetAuditLog(
        Guid matterId,
        [FromQuery] DateTime? from,
        [FromQuery] DateTime? to,
        [FromQuery] string? action,
        [FromQuery] int page = 1,
        [FromQuery] int pageSize = 50)
    {
        var result = await _auditService.GetAuditTrailAsync(
            GetTenantId(), matterId, from, to, action,
            page, pageSize);

        return Ok(new
        {
            result.Items,
            result.TotalCount,
            result.Page,
            result.PageSize,
            result.HasMore
        });
    }
}

Compliance Framework

StandardRequirementsOur Implementation
SOC 2 Type IIAccess controls, change management, monitoringRBAC, audit logs, real-time monitoring, annual penetration testing
GDPRData minimization, right to erasure, consentData retention policies, anonymization pipeline, consent tracking
HIPAAPHI protection, access logging, encryptionAES-256 encryption, role-based access, BAA with cloud providers
ABA Model RulesClient confidentiality, conflict screening, trust accountingDocument classification, conflict engine, IOLTA tracking
State Bar RulesJurisdiction-specific retention and accounting rulesConfigurable per-tenant compliance settings

17. Notification System

The notification system in a legal case management system must be reliable, multi-channel, and configurable. Missing a deadline notification is not an option. The system must deliver notifications through email, SMS, push notifications, and in-app channels, with each user able to configure their preferred channels and quiet hours. Notifications must be triggered by deadline reminders, document uploads, workflow step assignments, billing events, and client messages. Each notification delivery must be tracked and logged for audit purposes.

Notification Architecture

graph LR A[Event Sources] --> B[Notification Router] B --> C{Channel Router} C -->|High Priority| D[SMS - Twilio] C -->|Standard| E[Email - SendGrid] C -->|Real-Time| F[Push - Firebase] C -->|In-App| G[SignalR Hub] B --> H[Notification Store] H --> I[User Preferences] H --> J[Delivery Tracking]
C#
public class NotificationService : INotificationService
{
    private readonly IEmailSender _emailSender;
    private readonly ISmsSender _smsSender;
    private readonly IPushSender _pushSender;
    private readonly IHubContext<NotificationHub> _hubContext;
    private readonly LegalDbContext _dbContext;

    public async Task SendNotificationAsync(
        Guid tenantId, Guid userId,
        NotificationRequest request)
    {
        var user = await _dbContext.Users
            .Include(u => u.NotificationPreferences)
            .FirstAsync(u =>
                u.Id == userId && u.TenantId == tenantId);

        var preferences = user.NotificationPreferences
            .FirstOrDefault(p => p.Category == request.Category)
            ?? user.DefaultNotificationPreference;

        if (!preferences.IsEnabled) return;
        if (IsQuietHours(preferences)) return;

        var notification = new Notification
        {
            TenantId = tenantId,
            UserId = userId,
            Title = request.Title,
            Body = request.Body,
            Category = request.Category,
            Priority = request.Priority,
            RelatedEntityType = request.RelatedEntityType,
            RelatedEntityId = request.RelatedEntityId,
            CreatedAt = DateTime.UtcNow
        };

        _dbContext.Notifications.Add(notification);
        await _dbContext.SaveChangesAsync();

        var deliveryTasks = new List<Task>();

        if (preferences.EmailEnabled &&
            request.Priority >= NotificationPriority.Normal)
        {
            deliveryTasks.Add(SendEmailAsync(
                user.Email, notification));
        }

        if (preferences.SmsEnabled &&
            request.Priority >= NotificationPriority.High &&
            !string.IsNullOrEmpty(user.PhoneNumber))
        {
            deliveryTasks.Add(SendSmsAsync(
                user.PhoneNumber, notification));
        }

        if (preferences.PushEnabled &&
            request.Priority >= NotificationPriority.Normal)
        {
            deliveryTasks.Add(SendPushAsync(
                user.Id, notification));
        }

        if (preferences.InAppEnabled)
        {
            deliveryTasks.Add(SendInAppAsync(
                user.Id, notification));
        }

        await Task.WhenAll(deliveryTasks);
    }

    private bool IsQuietHours(
        NotificationPreference prefs)
    {
        if (!prefs.HasQuietHours) return false;
        var now = TimeOnly.FromDateTime(DateTime.UtcNow);
        return now >= prefs.QuietHoursStart &&
               now <= prefs.QuietHoursEnd;
    }
}

18. Reporting & Analytics

Reporting and analytics transform raw case data into actionable business intelligence for law firm management. Partners need to understand matter profitability, attorney utilization rates, client satisfaction trends, deadline compliance metrics, and practice area performance. The reporting system must support both pre-built dashboards for common KPIs and ad-hoc report generation for custom analyses. Large reports should be generated asynchronously to avoid blocking the main application thread, with results delivered via email or download links.

Key Performance Indicators

KPIFormulaTarget
Attorney Utilization RateBillable Hours / Available Hours75-85%
Matter Realization RateCollected Revenue / Billed Revenue88-95%
Deadline Compliance RateOn-Time Filings / Total Filings100%
Average Days to InvoiceAvg(Invoice Date - Service Date)< 15 days
Average Collection PeriodAvg(Payment Date - Invoice Date)< 45 days
Client Satisfaction ScoreAverage survey rating> 4.5/5
Cost per MatterTotal Firm Costs / Active MattersFirm-specific
Document Turnaround TimeAvg(Time to Complete Document Tasks)< 3 days

Report Generation Service

C#
public class ReportService : IReportService
{
    private readonly LegalDbContext _dbContext;
    private readonly IBlobStorage _blobStorage;

    public async Task<ReportResult> GenerateReportAsync(
        Guid tenantId, ReportRequest request)
    {
        var reportData = request.ReportType switch
        {
            ReportType.MatterProfitability =>
                await GenerateMatterProfitabilityAsync(
                    tenantId, request),
            ReportType.AttorneyUtilization =>
                await GenerateAttorneyUtilizationAsync(
                    tenantId, request),
            ReportType.BillingSummary =>
                await GenerateBillingSummaryAsync(
                    tenantId, request),
            ReportType.DeadlineCompliance =>
                await GenerateDeadlineComplianceAsync(
                    tenantId, request),
            ReportType.ClientActivity =>
                await GenerateClientActivityAsync(
                    tenantId, request),
            _ => throw new ArgumentException(
                $"Unknown report type: {request.ReportType}")
        };

        var reportId = Guid.NewGuid();
        var filePath = $"reports/{tenantId}/{reportId}.xlsx";

        using var stream = new MemoryStream();
        await ExportToExcelAsync(reportData, stream);
        stream.Position = 0;

        await _blobStorage.UploadAsync(filePath, stream,
            "application/vnd.openxmlformats" +
            "-officedocument.spreadsheetml.sheet");

        return new ReportResult
        {
            ReportId = reportId,
            DownloadUrl = $"/api/v1/reports/{reportId}/download",
            ExpiresAt = DateTime.UtcNow.AddDays(7),
            RowCount = reportData.Rows.Count
        };
    }

    private async Task<ReportData>
        GenerateMatterProfitabilityAsync(
        Guid tenantId, ReportRequest request)
    {
        var data = await _dbContext.Matters
            .Where(m => m.TenantId == tenantId)
            .Where(m =>
                m.OpenedDate >= request.StartDate)
            .Where(m =>
                m.OpenedDate <= request.EndDate)
            .Select(m => new
            {
                m.MatterNumber,
                m.Title,
                m.CaseType,
                TotalBilled = m.Invoices
                    .Where(i =>
                        i.Status != InvoiceStatus.Void)
                    .Sum(i => i.TotalAmount),
                TotalCollected = m.Invoices
                    .Where(i =>
                        i.Status == InvoiceStatus.Paid)
                    .Sum(i => i.TotalAmount),
                TotalHours = m.TimeEntries
                    .Where(t => t.Billable)
                    .Sum(t => t.Hours)
            }).ToListAsync();

        return new ReportData
        {
            Headers = new[] { "Matter #", "Title", "Type",
                "Total Billed", "Total Collected",
                "Billable Hours" },
            Rows = data.Select(d => new object[]
            {
                d.MatterNumber, d.Title, d.CaseType,
                d.TotalBilled, d.TotalCollected, d.TotalHours
            }).ToList()
        };
    }
}

19. Security & Encryption

Security in a legal case management system is paramount because the data it contains is protected by attorney-client privilege, work product doctrine, and various data protection regulations. A breach of legal case data can expose sensitive personal information, trade secrets, litigation strategy, and confidential business negotiations. The security architecture must implement defense in depth with encryption at rest and in transit, role-based access control, multi-factor authentication, IP whitelisting, session management, and comprehensive security monitoring.

Encryption Strategy

C#
public class FieldEncryptionService
{
    private readonly IKeyVaultClient _keyVault;

    private static readonly string[] EncryptedFields = new[]
    {
        "SocialSecurityNumber",
        "TaxIdentificationNumber",
        "BankAccountNumber",
        "CreditCardNumber",
        "MedicalRecordNumber",
        "PersonalAddress"
    };

    public async Task<T> EncryptSensitiveFieldsAsync<T>(
        T entity, Guid tenantId) where T : class
    {
        var type = typeof(T);
        var keyId = await _keyVault.GetKeyAsync(
            $"tenant-{tenantId}-fields");

        foreach (var fieldName in EncryptedFields)
        {
            var property = type.GetProperty(fieldName);
            if (property is null) continue;

            var value = property.GetValue(entity) as string;
            if (string.IsNullOrEmpty(value)) continue;

            var encrypted = await _keyVault.EncryptAsync(
                keyId, value);
            property.SetValue(entity, encrypted);
        }

        return entity;
    }

    public async Task<T> DecryptSensitiveFieldsAsync<T>(
        T entity, Guid tenantId) where T : class
    {
        var type = typeof(T);
        var keyId = await _keyVault.GetKeyAsync(
            $"tenant-{tenantId}-fields");

        foreach (var fieldName in EncryptedFields)
        {
            var property = type.GetProperty(fieldName);
            if (property is null) continue;

            var encryptedValue =
                property.GetValue(entity) as string;
            if (string.IsNullOrEmpty(encryptedValue)) continue;

            var decrypted = await _keyVault.DecryptAsync(
                keyId, encryptedValue);
            property.SetValue(entity, decrypted);
        }

        return entity;
    }
}

public class SecurityHeadersMiddleware
{
    private readonly RequestDelegate _next;

    public async Task InvokeAsync(HttpContext context)
    {
        context.Response.Headers.Append(
            "Strict-Transport-Security",
            "max-age=31536000; includeSubDomains");
        context.Response.Headers.Append(
            "X-Content-Type-Options", "nosniff");
        context.Response.Headers.Append(
            "X-Frame-Options", "DENY");
        context.Response.Headers.Append(
            "X-XSS-Protection", "1; mode=block");
        context.Response.Headers.Append(
            "Content-Security-Policy",
            "default-src 'self'; " +
            "script-src 'self' https://www.googletagmanager.com " +
            "https://pagead2.googlesyndication.com; " +
            "style-src 'self' 'unsafe-inline'; " +
            "img-src 'self' data: https:;");
        context.Response.Headers.Append(
            "Referrer-Policy",
            "strict-origin-when-cross-origin");
        context.Response.Headers.Append(
            "Permissions-Policy",
            "camera=(), microphone=(), geolocation=()");

        await _next(context);
    }
}

Role-Based Access Control

RolePermissionsData Scope
Managing PartnerFull access to all matters, billing, reports, adminAll firm matters
PartnerManage assigned matters, approve bills, view reportsAssigned matters + team matters
AssociateEdit matters, create documents, record timeAssigned matters only
ParalegalView matters, upload documents, calendar entriesAssigned matters only
Legal SecretaryView matters, manage calendar, draft documentsAssigned team matters
Billing ManagerManage invoices, run reports, trust reconciliationAll firm billing data
Client (External)View matter status, shared documents, invoicesOwn matters only
System AdministratorUser management, tenant config, security settingsConfiguration only

Security Checklist

  • All data encrypted at rest using AES-256 with per-tenant key isolation
  • TLS 1.3 enforced for all communications with certificate pinning for mobile clients
  • Multi-factor authentication required for all firm users with TOTP and FIDO2 support
  • JWT tokens with 15-minute expiration and secure refresh token rotation
  • Session timeout after 30 minutes of inactivity with re-authentication for sensitive actions
  • IP whitelisting for administrative access with geographic restrictions
  • Rate limiting on all API endpoints with stricter limits for authentication endpoints
  • Comprehensive security logging with real-time alerting for suspicious activity
  • Annual penetration testing by an independent third-party security firm
  • SOC 2 Type II compliance with continuous monitoring and annual audits
  • Data loss prevention controls to prevent bulk export of case documents
  • Secure document shredding when data retention periods expire

20. Cost Estimation

Understanding the infrastructure costs of running a legal case management platform is critical for business planning and pricing decisions. Legal data storage requirements are significant, the compliance overhead adds cost, and the availability requirements demand redundant infrastructure. Below is a detailed cost breakdown for a mid-scale deployment serving 500 law firms.

ComponentSpecificationMonthly Cost (USD)
PostgreSQL (Azure SQL MI)Business Critical, 16 vCores, 500 GB$3,500
Elasticsearch (Azure Cognitive Search)S3 tier, 50 GB index, 3 replicas$1,200
Blob Storage (Azure Blob)50 TB hot + 100 TB cool tier$2,500
Redis Cache (Azure Cache)Premium P2, 12 GB$600
Application Servers (AKS)6 nodes, D4s_v3 (4 vCPU, 16 GB)$1,800
API ManagementStandard tier, 1M calls/day$700
Service Bus (Message Queue)Standard tier$100
Key VaultStandard tier$50
SendGrid (Email)Essentials, 50K emails/month$200
Twilio (SMS)Pay-as-you-go, ~10K SMS/month$400
CDN (Azure Front Door)Standard, 1 TB transfer$300
Monitoring (Datadog)Pro plan, 10 hosts$500
Backup and DR (Geo-redundant)30-day retention, GRS$800
DNS and SSL CertificatesAzure DNS + DigiCert wildcard$100
Total Estimated$12,750
Cost Optimization Tips: Use reserved instances for a 40-60% discount on compute costs. Implement intelligent tiering for blob storage to automatically move infrequently accessed documents to cool and archive tiers. Use spot instances for non-critical batch processing workloads like report generation and document text extraction. Consider PostgreSQL Hyperscale (Citus) for multi-tenant sharding to reduce per-tenant database costs.

Revenue Model

With 500 law firms paying an average of $150 per user per month and an average of 10 users per firm, the monthly recurring revenue is 500 times 10 times $150 equals $750,000 per month, or $9 million annually. Against infrastructure costs of approximately $12,750 per month plus development and support staff costs, the platform achieves strong unit economics. The gross margin on infrastructure is approximately 98%, which is typical for SaaS platforms once they reach scale.

21. Testing Strategy

A comprehensive testing strategy for a legal case management system must cover unit tests for business logic, integration tests for database and service interactions, end-to-end tests for critical workflows, performance tests for load handling, security tests for vulnerability identification, and compliance tests for regulatory requirements. Given the high stakes of legal data management, we recommend a minimum test coverage of 85% across all services with mandatory code review for any changes to the billing, deadline, and audit subsystems.

Test Categories and Coverage

C#
// Unit Tests - MatterLifecycleEngine
public class MatterLifecycleEngineTests
{
    [Fact]
    public void CanTransition_FromIntakeToOpening_ReturnsTrue()
    {
        Assert.True(MatterLifecycleEngine.CanTransition(
            MatterStatus.Intake, MatterStatus.Opening));
    }

    [Fact]
    public void CanTransition_FromIntakeToClosed_ReturnsFalse()
    {
        Assert.False(MatterLifecycleEngine.CanTransition(
            MatterStatus.Intake, MatterStatus.Closed));
    }

    [Fact]
    public void CanTransition_FromActiveToOnHold_ReturnsTrue()
    {
        Assert.True(MatterLifecycleEngine.CanTransition(
            MatterStatus.Active, MatterStatus.OnHold));
    }

    [Theory]
    [InlineData(MatterStatus.Trial)]
    [InlineData(MatterStatus.Settlement)]
    [InlineData(MatterStatus.Closed)]
    public void CanTransition_FromDiscovery_ReturnsTrue(
        MatterStatus target)
    {
        Assert.True(MatterLifecycleEngine.CanTransition(
            MatterStatus.Discovery, target));
    }
}

// Integration Tests - Document Upload Flow
public class DocumentUploadIntegrationTests
    : IClassFixture<WebApplicationFactory>
{
    private readonly WebApplicationFactory _factory;

    public DocumentUploadIntegrationTests(
        WebApplicationFactory factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task UploadDocument_ValidFile_ReturnsCreated()
    {
        var client = _factory.CreateClient();
        client.DefaultRequestHeaders.Add(
            "X-Tenant-ID", "test-tenant");

        var fileContent = new byte[] {
            0x25, 0x50, 0x44, 0x46 };
        var matterId = Guid.NewGuid();

        var form = new MultipartFormDataContent();
        form.Add(new ByteArrayContent(fileContent),
            "file", "test.pdf");
        form.Add(new StringContent("Test Document"),
            "title");
        form.Add(new StringContent("Confidential"),
            "classification");

        var response = await client.PostAsync(
            $"/api/v1/matters/{matterId}/documents", form);

        Assert.Equal(
            HttpStatusCode.Created, response.StatusCode);
    }
}

// Deadline Calculation Tests
public class DeadlineCalculatorTests
{
    private readonly FederalDeadlineCalculator _calculator;

    public DeadlineCalculatorTests()
    {
        _calculator = new FederalDeadlineCalculator(
            new MockHolidayCalendar());
    }

    [Fact]
    public void CalculateLitigationDeadlines_FRCP12_Returns21BizDays()
    {
        var matter = new Matter
        {
            CaseType = CaseType.Litigation
        };
        var triggerDate = new DateTime(2026, 7, 1);

        var deadlines = _calculator
            .CalculateDeadlinesAsync(
                matter, triggerDate).Result;

        var responseDeadline = deadlines
            .First(d => d.RuleBasis.Contains("FRCP 12"));

        Assert.NotNull(responseDeadline);
        Assert.True(
            responseDeadline.DueDate > triggerDate);
    }
}

Testing Pyramid

graph TB A[End-to-End Tests - 10%] --> B[Integration Tests - 30%] B --> C[Unit Tests - 60%] A --- D[Critical Workflows] A --- E[User Journeys] A --- F[API Contracts] B --- G[Database Operations] B --- H[Service Integration] B --- I[External APIs] C --- J[Business Logic] C --- K[Data Validation] C --- L[Edge Cases]

22. Interview Q&A

The following questions and answers cover the most commonly asked interview questions related to designing a legal case management system. These questions are designed to test a candidate's understanding of distributed systems, database design, security, and the unique requirements of legal technology.

Q1: How would you handle a situation where a court deadline is calculated incorrectly due to a bug?

Answer: This is a critical incident scenario. First, I would implement an immediate mitigation process where all upcoming deadlines within the next 30 days are re-validated against the source jurisdictional rules through a background reconciliation job. Second, I would implement a dual-calculation system where every deadline is computed by two independent code paths and the results are compared. If they differ, the deadline is flagged for manual review and the system alerts the assigned attorney and managing partner. Third, I would maintain an immutable log of all deadline calculations with the rule version used, the input parameters, and the computed result, so that any discrepancies can be traced to their root cause. Prevention-wise, deadline calculation code would require mandatory peer review, automated testing with jurisdiction-specific test cases, and a canary deployment strategy where new rule changes are applied to a subset of matters before full rollout.

Q2: How do you prevent one tenant from accessing another tenant's data in a multi-tenant system?

Answer: Defense in depth is essential. At the application layer, every database query includes a mandatory WHERE clause filtering by tenant_id, enforced through EF Core global query filters that cannot be bypassed by application code. At the API layer, the tenant_id is extracted from the JWT token, not from client-supplied parameters, so a malicious client cannot override it. At the database layer, we use row-level security policies in PostgreSQL that enforce tenant isolation even if application code has a bug. At the infrastructure layer, each tenant's blob storage is in a separate container with separate access keys. Additionally, we run automated security tests that attempt cross-tenant access using tokens from different tenants, and any successful bypass triggers an immediate security alert.

Q3: How would you design the document versioning system to handle conflicts when two attorneys edit the same document simultaneously?

Answer: Legal documents are primarily read-heavy with write operations that are relatively infrequent compared to collaborative editing tools like Google Docs. We use an optimistic concurrency model where each document version has a monotonically increasing version number. When an attorney uploads a new version, the system checks that the version number matches the current HEAD. If it does, the upload succeeds and the version number is incremented. If another attorney has uploaded a version in the meantime, the system rejects the upload and returns a conflict response with the current version information, allowing the client to download the latest version, merge the changes, and re-upload. For real-time collaborative editing of legal documents, we would integrate a CRDT-based or OT-based collaborative editing engine like Yjs or ShareDB, but this is typically handled by dedicated document editing platforms rather than the case management system itself.

Q4: Explain your approach to ensuring the audit trail is tamper-proof.

Answer: We implement a blockchain-inspired integrity chain where each audit log entry includes a hash of the previous entry, creating a tamper-evident chain. If any entry is modified, all subsequent hashes become invalid. The audit logs are written to an append-only table with database-level permissions that prevent UPDATE and DELETE operations even from the application service account. We also ship audit logs in real-time to a separate, write-only storage system (such as Azure Immutable Blob Storage with time-based retention policies) that physically prevents modification for the configured retention period. Periodic integrity checks run as background jobs that verify the hash chain and alert if any discontinuity is detected. For extra assurance, we periodically generate Merkle tree roots of audit entries and store them with an external notarization service.

Q5: How would you handle a situation where the Elasticsearch index falls out of sync with the PostgreSQL source of truth?

Answer: We use the transactional outbox pattern to ensure reliable event delivery. When a document is uploaded or modified, the application writes both the database change and an event record to an outbox table in the same database transaction. A separate background process polls the outbox table and publishes events to the message queue, which the search indexer consumes. If an event fails to be processed after the maximum retry count, it is moved to a dead letter queue where it can be manually inspected and replayed. We also run a reconciliation job daily that compares document counts and checksums between PostgreSQL and Elasticsearch, logging any discrepancies and triggering a re-index for affected documents. This approach provides at-least-once delivery guarantees with idempotent consumers to handle potential duplicate events.

Q6: How do you scale the deadline notification system to handle millions of reminders?

Answer: The deadline notification system uses a scheduling service backed by a persistent job queue. When a deadline is created, the system calculates all required reminder dates and enqueues individual notification jobs for each reminder date. These jobs are stored in a dedicated table with an index on the scheduled execution time. A polling service queries for jobs where the scheduled time has passed and the job is not yet completed, batching them into groups of 100 for efficient processing. Each notification job is processed independently with retry logic and dead letter queue handling for failures. For peak load management, we implement token bucket rate limiting for external notification channels (email and SMS) and prioritize court filing reminders over internal milestone notifications. The system scales horizontally by adding more worker instances that compete for jobs from the queue, with distributed locking to prevent duplicate processing.

Q7: Design a conflict of interest check that can handle 100,000 parties efficiently.

Answer: We pre-compute a party search index using a combination of exact-match hash maps and approximate matching data structures. For exact matching, we maintain a Dictionary<string, List<PartyRecord>> keyed by the normalized party name (lowercased, trimmed, with common suffixes like Inc and LLC removed). This gives O(1) lookup for exact name matches. For fuzzy matching, we use a BK-tree data structure with Levenshtein distance as the metric, which allows efficient nearest-neighbor lookups without comparing against every record. For very large firm histories, we shard the party index by first letter of the normalized name and load only the relevant shard into memory. The entity relationship graph for indirect conflict detection is stored in Neo4j or as an adjacency list in Redis, enabling graph traversal queries with a bounded depth. The entire screening process completes in under 200 milliseconds for 100,000 parties, well within the acceptable range for a synchronous API call during matter intake.

Q8: How would you handle document retention and destruction for matters that have exceeded their retention period?

Answer: We implement a scheduled retention service that runs weekly and identifies matters where the retention period has expired based on the matter's closed date and the tenant's configured retention policy. Before any destruction, the service verifies that no litigation hold is active for the matter, that no regulatory investigation requires the documents, and that the matter does not fall under any exception category (such as minor client matters which may require longer retention in some jurisdictions). Documents marked for destruction are first moved to a quarantine state where they are invisible to users but still exist for a configurable grace period (typically 30 days) to allow for accidental destruction recovery. After the grace period, documents are permanently deleted from blob storage using secure deletion methods, metadata is purged from PostgreSQL, and the search index entries are removed. A destruction certificate is generated and added to the audit log for each batch of destroyed documents.

© 2026 Ayodhyya. All rights reserved. | Design a Legal Case Management System — A Senior+ Guide