Skip to main content

NumberSequence Aggregate

File: /docs/domain/shared/aggregates/number-sequence.aggregate.md
Module: Shared (GeneralLedger.Domain)
Namespace: GeneralLedger.Domain.Aggregates.NumberSequenceAggregate


Purpose

The NumberSequence aggregate provides centralized, configurable sequential number generation for business documents and transactions across all ERP modules. It ensures uniqueness, maintains audit trails, and supports flexible formatting patterns that comply with organizational numbering standards and regulatory requirements.

This aggregate acts as a shared service that multiple modules consume to generate consistent, traceable identifiers for journals, invoices, vouchers, payments, and other business documents.


Business Context

In enterprise systems, consistent and auditable numbering is critical for:

  • Regulatory Compliance: Many jurisdictions require continuous, gap-free numbering for invoices and financial documents
  • Audit Trail: Sequential numbers enable chronological tracking and prevent document tampering
  • Organization Standards: Companies need configurable formats that match their internal conventions (e.g., "INV-2025-0001")
  • Departmental Separation: Different departments may use different number sequences (e.g., Sales vs. Purchasing)
  • Cross-Module Coordination: Multiple modules (GL, AR, AP) need coordinated numbering without conflicts

Real-World Example:

Sales Invoice Sequence:  SI-2025-0001, SI-2025-0002, SI-2025-0003
Purchase Order Sequence: PO-2025-0001, PO-2025-0002, PO-2025-0003
Payment Voucher Sequence: PAY-2025-001, PAY-2025-002, PAY-2025-003

Aggregate Structure

Root Entity: NumberSequence

Identity: Guid Id (inherited from AggregateRootBase)

Core Properties:

  • Name (string): Unique identifier for the sequence (e.g., "Sales Invoice Numbers")
  • Format (string): Format pattern using '#' placeholders (e.g., "INV-####")
  • CurrentValue (int): Last generated number
  • NextValue (int): Next number to be generated
  • IsActive (bool): Controls whether the sequence can generate new numbers

Child Entities:

  • References (List<NumberSequenceReference>): Many-to-many associations with ModuleFeatures

Child Entity: NumberSequenceReference

Purpose: Links the NumberSequence to specific module features (transaction types) that use it.

Properties:

  • NumberSequenceId (Guid): Foreign key to NumberSequence
  • DataTypeId (int): Foreign key to ModuleFeature enumeration
  • DataType (ModuleFeature): Navigation property to the feature

Relationship Pattern: One NumberSequence can serve multiple ModuleFeatures, but each ModuleFeature should reference only one active NumberSequence at a time.


Business Rules & Invariants

Core Validation Rules

  1. Unique Name Requirement

    • Rule: Name must be unique across all NumberSequences
    • Enforcement: Application layer validation + database unique constraint
    • Rationale: Prevents configuration confusion and ambiguity
  2. Format Structure

    • Rule: Format must contain at least one '#' placeholder
    • Enforcement: Domain validation in constructor and Update method
    • Example Valid Formats: "INV-####", "PO-2025-####", "V-####-A"
    • Example Invalid Formats: "INVOICE", "2025" (no placeholders)
  3. Non-Negative Current Value

    • Rule: CurrentValue cannot be negative
    • Enforcement: Domain validation during updates
    • Rationale: Sequence numbers must start from 0 or positive integers
  4. Active State Requirement for Generation

    • Rule: GetNextNumber() can only be called if IsActive = true
    • Enforcement: Domain method validation
    • Exception: InvalidOperationException if called on inactive sequence
  5. Concurrency Control

    • Rule: Number generation must be thread-safe and prevent duplicates
    • Enforcement: Pessimistic database locking (FOR UPDATE in repository)
    • Pattern: Row-level lock acquired before calling GetNextNumber()

Reference Management Rules

  1. Idempotent Reference Addition

    • Rule: Adding a reference that already exists does nothing (no error)
    • Enforcement: AddReference() checks for existing DataTypeId before adding
    • Rationale: Simplifies configuration management
  2. No Orphaned References

    • Rule: ModuleFeature should reference only one active sequence
    • Enforcement: Application layer coordination
    • Migration Pattern: Remove old reference before adding new one

Format Parsing Rules

Format Placeholder Rules:

  • # = Single numeric digit
  • Any other character = Literal character in output
  • Consecutive # symbols define the zero-padded width

Examples:

Format: "INV-####"     → CurrentValue: 42   → Output: "INV-0042"
Format: "PO-######" → CurrentValue: 5 → Output: "PO-000005"
Format: "V-2025-###" → CurrentValue: 123 → Output: "V-2025-123"
Format: "DOC-####-X" → CurrentValue: 7 → Output: "DOC-0007-X"

State Management

Lifecycle States

State Transitions

Activation

sequence.Activate();
// IsActive: false → true
// Enables number generation

Preconditions: None (can activate anytime)
Postconditions: IsActive = true
Use Case: Enabling a newly configured or previously deactivated sequence

Deactivation

sequence.Deactivate();
// IsActive: true → false
// Prevents number generation

Preconditions: None (can deactivate anytime)
Postconditions: IsActive = false
Use Case: Temporarily disabling a sequence without deleting historical configuration

Number Generation

string nextNumber = sequence.GetNextNumber();
// CurrentValue: n → n+1
// NextValue: n+1 → n+2
// Returns: Formatted number based on CurrentValue

Preconditions:

  • IsActive = true
  • Caller has acquired row-level lock (repository responsibility)

Postconditions:

  • CurrentValue incremented by 1
  • NextValue incremented by 1
  • Formatted number returned

Thread Safety: Repository's GetByIdForUpdateAsync() acquires pessimistic lock


Key Methods

Constructor

public NumberSequence(string name, string format)

Purpose: Creates a new NumberSequence with initial configuration.

Validation:

  • name cannot be null or whitespace
  • format cannot be null or whitespace
  • format must contain at least one '#'

Initial State:

  • CurrentValue = 0
  • NextValue = 1
  • IsActive = true

Example:

var sequence = new NumberSequence(
"Sales Invoice Numbers",
"SI-2025-####"
);

GetNextNumber()

public string GetNextNumber()

Purpose: Generates and returns the next formatted number in the sequence.

Behavior:

  1. Checks if sequence is active (throws if not)
  2. Increments CurrentValue
  3. Increments NextValue
  4. Formats the new CurrentValue according to Format
  5. Returns formatted string

Thread Safety: Must be called within a database transaction with row-level lock.

Example Usage:

// Sequence: "INV-####", CurrentValue: 41
string nextNumber = sequence.GetNextNumber();
// Result: "INV-0042"
// CurrentValue is now 42, NextValue is now 43

Update()

public void Update(
string? name = null,
string? format = null,
int? currentValue = null,
bool? isActive = null)

Purpose: Updates mutable properties of the sequence.

Validation:

  • If format provided, must contain '#'
  • If currentValue provided, must be non-negative
  • If currentValue changed, NextValue is recalculated as currentValue + 1

Use Cases:

  • Rename sequence: sequence.Update(name: "New Name")
  • Change format: sequence.Update(format: "NEW-####")
  • Reset counter: sequence.Update(currentValue: 0)
  • Toggle active state: sequence.Update(isActive: false)

AddReference()

public void AddReference(ModuleFeature dataType)

Purpose: Associates a ModuleFeature with this NumberSequence.

Behavior: Idempotent - if reference already exists, does nothing.

Example:

sequence.AddReference(GeneralLedgerFeature.JournalBatchNumber);
sequence.AddReference(AccountsReceivableFeature.SalesInvoices);

RemoveReference()

public bool RemoveReference(ModuleFeature dataType)

Purpose: Dissociates a ModuleFeature from this NumberSequence.

Returns: true if reference was found and removed, false otherwise.

Example:

bool removed = sequence.RemoveReference(GeneralLedgerFeature.JournalBatchNumber);

UpdateReference()

public bool UpdateReference(ModuleFeature oldDataType, ModuleFeature newDataType)

Purpose: Changes a reference from one ModuleFeature to another.

Validation: Throws if newDataType already has a reference in this sequence.

Returns: true if update succeeded, false if old reference not found.

ClearReferences()

public void ClearReferences()

Purpose: Removes all ModuleFeature references.

Use Case: Bulk reference reconfiguration.


Domain Events

Current Implementation

The NumberSequence aggregate does not currently publish domain events. Its state changes are managed synchronously within transactional boundaries.

Future Event Considerations

NumberSequenceCreatedDomainEvent

public class NumberSequenceCreatedDomainEvent : IDomainEvent
{
public Guid NumberSequenceId { get; }
public string Name { get; }
public string Format { get; }

// Use Case: Audit logging, cache initialization
}

NumberSequenceNumberGeneratedDomainEvent

public class NumberSequenceNumberGeneratedDomainEvent : IDomainEvent
{
public Guid NumberSequenceId { get; }
public int GeneratedValue { get; }
public string FormattedNumber { get; }
public DateTime GeneratedAt { get; }

// Use Case: High-frequency monitoring, gap detection, analytics
}

NumberSequenceDepletedDomainEvent

public class NumberSequenceDepletedDomainEvent : IDomainEvent
{
public Guid NumberSequenceId { get; }
public int MaxValue { get; }

// Use Case: Alert administrators before sequence exhaustion
}

Rationale for Future Events:

  • Audit requirements may necessitate event-based tracking of all number generation
  • Analytics systems may need real-time sequence usage data
  • Proactive monitoring can prevent sequence exhaustion issues

Repository Contract

INumberSequenceRepository

Purpose: Provides data access and concurrency control for NumberSequence operations.

Core Query Methods

/// <summary>
/// Standard retrieval with eager loading of references
/// </summary>
Task<NumberSequence?> GetByIdAsync(Guid id);

/// <summary>
/// CRITICAL: Acquires pessimistic row lock (FOR UPDATE)
/// Must be used before calling GetNextNumber() to prevent race conditions
/// </summary>
Task<NumberSequence?> GetByIdForUpdateAsync(Guid id);

/// <summary>
/// Retrieves sequence by unique name
/// </summary>
Task<NumberSequence?> GetByNameAsync(string name);

/// <summary>
/// Gets all sequences with references
/// </summary>
Task<IEnumerable<NumberSequence>> GetAllAsync();

/// <summary>
/// Finds the sequence assigned to a specific ModuleFeature
/// </summary>
Task<NumberSequence?> GetByDataTypeAsync(ModuleFeature dataType);

/// <summary>
/// Finds sequence by ModuleFeature name (string-based lookup)
/// </summary>
Task<NumberSequence?> GetByDataTypeNameAsync(string dataType);

Command Methods

/// <summary>
/// Adds new sequence to repository
/// </summary>
NumberSequence Add(NumberSequence sequence);

/// <summary>
/// Marks sequence as modified
/// </summary>
void Update(NumberSequence sequence);

Critical Concurrency Pattern

The FOR UPDATE Pattern:

// In PostgreSQL/Repository Layer
var sequence = await _context.NumberSequences
.FromSqlRaw("SELECT * FROM general_ledger.number_sequences WHERE id = {0} FOR UPDATE", id)
.FirstOrDefaultAsync();

Why This Matters:

  • Prevents two transactions from generating the same number
  • Blocks concurrent access until the first transaction commits
  • Ensures gap-free sequential numbering

Usage Pattern:

// CORRECT: Acquire lock before generation
var sequence = await _repository.GetByIdForUpdateAsync(sequenceId);
string number = sequence.GetNextNumber();
await _unitOfWork.SaveChangesAsync(); // Releases lock

// INCORRECT: No lock - race condition possible
var sequence = await _repository.GetByIdAsync(sequenceId); // NO LOCK!
string number = sequence.GetNextNumber(); // DANGER: Duplicate risk
await _unitOfWork.SaveChangesAsync();

Integration with Other Aggregates

Relationship with LedgerJournalName

Pattern: Configuration Reference

public class LedgerJournalName
{
public Guid VoucherSeriesId { get; private set; } // FK to NumberSequence
public NumberSequence VoucherSeries { get; private set; }
}

Usage: LedgerJournalName stores a reference to the NumberSequence that will be used for voucher generation.

Workflow:

  1. Admin configures LedgerJournalName, selects a NumberSequence
  2. When user creates journal, voucher numbers are generated from the referenced sequence
  3. VoucherGenerationStrategy determines WHEN to call the sequence

Relationship with ModuleFeature (Enumeration)

Pattern: Many-to-Many through NumberSequenceReference

ModuleFeature Examples:

// General Ledger Features (ID: 1-100)
GeneralLedgerFeature.JournalBatchNumber (1)
GeneralLedgerFeature.GeneralJournalReversalVoucher (4)

// Accounts Receivable Features (ID: 101-200)
AccountsReceivableFeature.SalesInvoices (102)
AccountsReceivableFeature.SalesInvoiceVoucher (103)
AccountsReceivableFeature.CustomerPaymentJournal (104)

// Accounts Payable Features (ID: 201-300)
AccountsPayableFeature.PurchaseInvoices (202)
AccountsPayableFeature.PurchaseInvoiceVoucher (203)
AccountsPayableFeature.VendorPaymentJournal (204)

Configuration Workflow:

// Create sequence
var invoiceSequence = new NumberSequence("Sales Invoice Numbers", "SI-2025-####");

// Associate with features
invoiceSequence.AddReference(AccountsReceivableFeature.SalesInvoices);
invoiceSequence.AddReference(AccountsReceivableFeature.SalesInvoiceVoucher);

// Now both features use the same sequence

Relationship with INumberSequenceService

Pattern: Application Service Consumption

public interface INumberSequenceService
{
Task<string> GetNextNumberAsync(ModuleFeature feature);
Task<string> GetNextNumberAsync(Guid numberSequenceId);
}

Service Responsibilities:

  1. Resolve ModuleFeature to NumberSequence
  2. Acquire pessimistic lock via GetByIdForUpdateAsync()
  3. Call domain method GetNextNumber()
  4. Coordinate with Unit of Work for transaction management

Example Usage in Command Handler:

// Approach 1: By ModuleFeature
string journalNumber = await _numberSequenceService.GetNextNumberAsync(
GeneralLedgerFeature.JournalBatchNumber
);

// Approach 2: By NumberSequence ID (when known)
string voucherNumber = await _numberSequenceService.GetNextNumberAsync(
journal.VoucherSeriesId
);

Usage Patterns

Pattern 1: Creating and Configuring a Sequence

// Application Layer - CreateNumberSequenceCommandHandler
public async Task<Guid> Handle(CreateNumberSequenceCommand command)
{
// 1. Validate uniqueness
var existing = await _repository.GetByNameAsync(command.Name);
if (existing != null)
{
throw new InvalidOperationException($"Sequence '{command.Name}' already exists");
}

// 2. Create aggregate
var sequence = new NumberSequence(command.Name, command.Format);

// 3. Set starting value if requested (optional)
if (command.StartingValue > 0)
{
for (int i = 0; i < command.StartingValue; i++)
{
sequence.GetNextNumber(); // Advance to starting value
}
}

// 4. Add ModuleFeature references
foreach (var featureId in command.ModuleFeatureIds)
{
var feature = Enumeration.FromValue<ModuleFeature>(featureId);
sequence.AddReference(feature);
}

// 5. Persist
_repository.Add(sequence);
await _unitOfWork.SaveChangesAsync();

return sequence.Id;
}

Pattern 2: Generating Numbers with Transaction Safety

// Application Service - NumberSequenceService
public async Task<string> GetNextNumberAsync(Guid numberSequenceId)
{
// CRITICAL: Acquire row-level lock
var sequence = await _repository.GetByIdForUpdateAsync(numberSequenceId);

if (sequence == null)
{
throw new RecordNotFoundException("NumberSequence", numberSequenceId);
}

// Generate number (increments counter in memory)
string nextNumber = sequence.GetNextNumber();

// Mark as modified
_repository.Update(sequence);

// IMPORTANT: Caller's Unit of Work will save and release lock
return nextNumber;
}

Transaction Pattern:

// Command Handler orchestrates the transaction
using var transaction = await _unitOfWork.BeginTransactionAsync();
try
{
// Generate number within transaction
string invoiceNumber = await _numberSequenceService.GetNextNumberAsync(
AccountsReceivableFeature.SalesInvoices
);

// Create invoice with generated number
var invoice = new SalesInvoice(invoiceNumber, customerId, ...);
_invoiceRepository.Add(invoice);

// Commit: Persists both the invoice and the incremented sequence
await _unitOfWork.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
// Lock is released after commit or rollback

Pattern 3: Reassigning a ModuleFeature to a Different Sequence

// Application Layer - UpdateModuleFeatureNumberSequenceCommandHandler
public async Task<bool> Handle(UpdateModuleFeatureNumberSequenceCommand command)
{
var moduleFeature = ModuleFeature.FromValue(command.ModuleFeatureId);

// Get target sequence
var targetSequence = await _repository.GetByIdAsync(command.TargetSequenceId);
if (targetSequence == null)
{
throw new RecordNotFoundException("NumberSequence", command.TargetSequenceId);
}

// Find current sequence (if any)
var currentSequence = await _repository.GetByDataTypeAsync(moduleFeature);

// Check if already assigned to target
if (currentSequence?.Id == targetSequence.Id)
{
return true; // Already configured correctly
}

// Remove old reference
if (currentSequence != null)
{
currentSequence.RemoveReference(moduleFeature);
_repository.Update(currentSequence);
}

// Add new reference
targetSequence.AddReference(moduleFeature);
_repository.Update(targetSequence);

// Persist both changes
await _unitOfWork.SaveChangesAsync();

return true;
}

Pattern 4: Bulk Configuration with Multiple References

// Setup: Configure all AR invoice-related features to use same sequence
var arInvoiceSequence = new NumberSequence(
"AR Invoice Master Sequence",
"INV-2025-#####"
);

// Add all related features
arInvoiceSequence.AddReference(AccountsReceivableFeature.SalesInvoices);
arInvoiceSequence.AddReference(AccountsReceivableFeature.SalesInvoiceVoucher);
arInvoiceSequence.AddReference(AccountsReceivableFeature.ReturnInvoice);
arInvoiceSequence.AddReference(AccountsReceivableFeature.ReturnInvoiceVoucher);

_repository.Add(arInvoiceSequence);
await _unitOfWork.SaveChangesAsync();

Performance Considerations

Concurrency Bottlenecks

The Problem: NumberSequence is a high-contention resource. If 100 users create invoices simultaneously, they all need locks on the same sequence row.

Mitigation Strategies:

  1. Minimize Lock Duration

    // GOOD: Acquire lock, generate, release quickly
    var sequence = await _repository.GetByIdForUpdateAsync(id);
    string number = sequence.GetNextNumber();
    await _unitOfWork.SaveChangesAsync(); // < 10ms

    // BAD: Hold lock while doing expensive operations
    var sequence = await _repository.GetByIdForUpdateAsync(id);
    string number = sequence.GetNextNumber();
    // ... perform complex validation, external API calls, etc.
    await _unitOfWork.SaveChangesAsync(); // Could be seconds!
  2. Separate Sequences by Department/Type

    // Instead of one sequence for all invoices:
    salesInvoiceSequence // Sales department
    serviceInvoiceSequence // Service department
    exportInvoiceSequence // Export department

    // Reduces contention by 3x in this example
  3. Connection Pooling

    • Ensure adequate database connection pool size
    • Monitor for connection pool exhaustion under load

Caching Strategy

Don't Cache the Aggregate: NumberSequence state changes frequently and caching can lead to stale data and duplicate numbers.

Do Cache the Mapping: ModuleFeature → NumberSequenceId mapping changes rarely:

public class CachedNumberSequenceService : INumberSequenceService
{
private readonly IMemoryCache _cache;
private readonly INumberSequenceRepository _repository;

public async Task<string> GetNextNumberAsync(ModuleFeature feature)
{
// Cache the mapping, not the sequence itself
var sequenceId = await _cache.GetOrCreateAsync(
$"ModuleFeature:{feature.Id}:SequenceId",
async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
var seq = await _repository.GetByDataTypeAsync(feature);
return seq?.Id;
}
);

if (sequenceId == null)
{
throw new InvalidOperationException($"No sequence configured for {feature.Name}");
}

// Always fetch fresh for generation (with lock)
var sequence = await _repository.GetByIdForUpdateAsync(sequenceId.Value);
return sequence.GetNextNumber();
}
}

Database Indexing

Critical Indexes:

-- Primary key (automatic)
CREATE UNIQUE INDEX PK_number_sequences ON number_sequences (id);

-- Unique name lookup
CREATE UNIQUE INDEX UQ_number_sequences_name ON number_sequences (name);

-- ModuleFeature lookup (on reference table)
CREATE INDEX IX_number_sequence_references_data_type
ON number_sequence_references (data_type_id);

-- Covering index for frequent query
CREATE INDEX IX_number_sequence_references_covering
ON number_sequence_references (data_type_id)
INCLUDE (number_sequence_id);

Scalability Metrics

Target Performance (single sequence, PostgreSQL):

  • Number generation latency: < 10ms (99th percentile)
  • Concurrent requests: 100+ simultaneous users
  • Throughput: 500-1000 numbers/second per sequence

Real-World Bottlenecks:

  • Database lock wait time is the primary constraint
  • Network latency between application and database
  • Transaction commit overhead

Validation Rules

Creation Validation

public class CreateNumberSequenceValidator : AbstractValidator<CreateNumberSequenceCommand>
{
public CreateNumberSequenceValidator(INumberSequenceRepository repository)
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name is required")
.MaximumLength(100).WithMessage("Name cannot exceed 100 characters")
.MustAsync(async (name, ct) =>
await repository.GetByNameAsync(name) == null)
.WithMessage("A number sequence with this name already exists");

RuleFor(x => x.Format)
.NotEmpty().WithMessage("Format is required")
.Must(format => format.Contains('#'))
.WithMessage("Format must contain at least one '#' placeholder")
.MaximumLength(50).WithMessage("Format cannot exceed 50 characters");

RuleFor(x => x.StartingValue)
.GreaterThanOrEqualTo(0)
.When(x => x.StartingValue.HasValue)
.WithMessage("Starting value cannot be negative");

RuleForEach(x => x.ModuleFeatureIds)
.Must(BeValidModuleFeature)
.WithMessage("Invalid module feature ID");
}

private bool BeValidModuleFeature(int featureId)
{
try
{
Enumeration.FromValue<ModuleFeature>(featureId);
return true;
}
catch
{
return false;
}
}
}

Update Validation

public class UpdateNumberSequenceValidator : AbstractValidator<UpdateNumberSequenceCommand>
{
public UpdateNumberSequenceValidator(INumberSequenceRepository repository)
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(100)
.MustAsync(async (cmd, name, ct) =>
{
if (string.IsNullOrEmpty(name)) return true;
var existing = await repository.GetByNameAsync(name);
return existing == null || existing.Id == cmd.Id;
})
.WithMessage("A number sequence with this name already exists");

RuleFor(x => x.Format)
.Must(format => string.IsNullOrEmpty(format) || format.Contains('#'))
.WithMessage("Format must contain at least one '#' placeholder");

RuleFor(x => x.CurrentValue)
.GreaterThanOrEqualTo(0)
.When(x => x.CurrentValue.HasValue)
.WithMessage("Current value cannot be negative");
}
}

Testing Considerations

Unit Tests (Domain Layer)

[Fact]
public void GetNextNumber_WhenInactive_ShouldThrowException()
{
// Arrange
var sequence = new NumberSequence("Test", "T-###");
sequence.Deactivate();

// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() =>
sequence.GetNextNumber());

Assert.Contains("inactive sequence", exception.Message);
}

[Fact]
public void GetNextNumber_ShouldFormatCorrectly()
{
// Arrange
var sequence = new NumberSequence("Test", "INV-####");

// Act
string first = sequence.GetNextNumber(); // CurrentValue becomes 1
string second = sequence.GetNextNumber(); // CurrentValue becomes 2

// Assert
Assert.Equal("INV-0001", first);
Assert.Equal("INV-0002", second);
}

[Fact]
public void Constructor_WithInvalidFormat_ShouldThrowException()
{
// Act & Assert
var exception = Assert.Throws<ArgumentException>(() =>
new NumberSequence("Test", "NO_PLACEHOLDERS"));

Assert.Contains("'#' placeholder", exception.Message);
}

[Fact]
public void AddReference_WhenAlreadyExists_ShouldBeIdempotent()
{
// Arrange
var sequence = new NumberSequence("Test", "T-###");
var feature = GeneralLedgerFeature.JournalBatchNumber;

// Act
sequence.AddReference(feature);
sequence.AddReference(feature); // Add again

// Assert
Assert.Single(sequence.References);
}

Integration Tests (Concurrency)

[Fact]
public async Task GetNextNumber_ConcurrentCalls_ShouldGenerateUniqueNumbers()
{
// Arrange
var sequence = new NumberSequence("Concurrent Test", "C-####");
_repository.Add(sequence);
await _unitOfWork.SaveChangesAsync();

// Act: Simulate 10 concurrent requests
var tasks = Enumerable.Range(0, 10).Select(async _ =>
{
using var scope = _serviceProvider.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<INumberSequenceService>();
return await service.GetNextNumberAsync(sequence.Id);
});

var numbers = await Task.WhenAll(tasks);

// Assert: All numbers should be unique
Assert.Equal(10, numbers.Distinct().Count());
Assert.Contains("C-0001", numbers);
Assert.Contains("C-0010", numbers);
}

Common Configuration Examples

Example 1: Sales Invoice Sequence

var salesInvoice = new NumberSequence(
name: "Sales Invoice Numbers",
format: "SI-2025-#####"
);

salesInvoice.AddReference(AccountsReceivableFeature.SalesInvoices);
salesInvoice.AddReference(AccountsReceivableFeature.SalesInvoiceVoucher);

// Generated numbers: SI-2025-00001, SI-2025-00002, ...

Example 2: Multi-Year Format with Auto-Year

// Note: Current implementation doesn't support dynamic year placeholder
// This would require enhancement to format parser

var sequence = new NumberSequence(
name: "Purchase Orders",
format: "PO-#####" // Year added programmatically
);

// In application layer, inject year:
string baseNumber = sequence.GetNextNumber(); // "PO-00001"
string withYear = $"PO-{DateTime.Now.Year}-{baseNumber.Split('-')[1]}";
// Result: "PO-2025-00001"

Example 3: Department-Specific Sequences

var salesSequence = new NumberSequence("Sales Journal", "SJ-####");
salesSequence.AddReference(GeneralLedgerFeature.JournalBatchNumber);

var purchasingSequence = new NumberSequence("Purchasing Journal", "PJ-####");
// Would need separate ModuleFeature or configuration to distinguish

// This avoids contention between departments

Example 4: Voucher Sequences for Different Journal Types

var dailyVoucher = new NumberSequence("Daily Vouchers", "V-2025-####");
var paymentVoucher = new NumberSequence("Payment Vouchers", "PAY-####");
var adjustmentVoucher = new NumberSequence("Adjustment Vouchers", "ADJ-####");

// Each journal type uses its own sequence
// Configured via LedgerJournalName.VoucherSeriesId

Troubleshooting Guide

Issue 1: "Cannot get next number from inactive sequence"

Cause: Calling GetNextNumber() on a sequence where IsActive = false.

Diagnosis:

var sequence = await _repository.GetByIdAsync(sequenceId);
Console.WriteLine($"IsActive: {sequence.IsActive}");

Solution:

// Reactivate the sequence
sequence.Activate();
_repository.Update(sequence);
await _unitOfWork.SaveChangesAsync();

Issue 2: Duplicate Numbers Generated

Cause: Missing pessimistic lock before calling GetNextNumber().

Diagnosis: Check if GetByIdForUpdateAsync() is being used:

// WRONG - No lock
var sequence = await _repository.GetByIdAsync(id);

// CORRECT - With lock
var sequence = await _repository.GetByIdForUpdateAsync(id);

Solution: Always use GetByIdForUpdateAsync() in production code.

Issue 3: Sequence Exhaustion

Cause: Format allows fewer digits than needed (e.g., "##" only allows 0-99).

Diagnosis:

var sequence = await _repository.GetByIdAsync(sequenceId);
Console.WriteLine($"Format: {sequence.Format}");
Console.WriteLine($"Current Value: {sequence.CurrentValue}");

int maxValue = (int)Math.Pow(10, sequence.Format.Count(c => c == '#')) - 1;
Console.WriteLine($"Max Value: {maxValue}");
Console.WriteLine($"Remaining: {maxValue - sequence.CurrentValue}");

Solution:

// Update format to support more numbers
sequence.Update(format: "INV-######"); // Now supports 0-999999
_repository.Update(sequence);
await _unitOfWork.SaveChangesAsync();

Issue 4: Number Gaps After Transaction Rollback

Cause: GetNextNumber() was called but transaction rolled back.

Explanation: This is by design in most systems for simplicity. True gap-free numbering requires complex two-phase commit.

Solution Options:

  1. Accept gaps (most common in modern systems)
  2. Implement sequence reservation system (complex)
  3. Use database sequences with NOCACHE option (PostgreSQL-specific)

Domain Aggregates

Conceptual Documentation

API Documentation

Cross-Module References


Last Updated: 2025-01-15 | Version: 1.0 | Status: Production