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 numberNextValue(int): Next number to be generatedIsActive(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 NumberSequenceDataTypeId(int): Foreign key to ModuleFeature enumerationDataType(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
-
Unique Name Requirement
- Rule:
Namemust be unique across all NumberSequences - Enforcement: Application layer validation + database unique constraint
- Rationale: Prevents configuration confusion and ambiguity
- Rule:
-
Format Structure
- Rule:
Formatmust 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)
- Rule:
-
Non-Negative Current Value
- Rule:
CurrentValuecannot be negative - Enforcement: Domain validation during updates
- Rationale: Sequence numbers must start from 0 or positive integers
- Rule:
-
Active State Requirement for Generation
- Rule:
GetNextNumber()can only be called ifIsActive = true - Enforcement: Domain method validation
- Exception:
InvalidOperationExceptionif called on inactive sequence
- Rule:
-
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
-
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
-
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:
CurrentValueincremented by 1NextValueincremented 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:
namecannot be null or whitespaceformatcannot be null or whitespaceformatmust contain at least one '#'
Initial State:
CurrentValue = 0NextValue = 1IsActive = 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:
- Checks if sequence is active (throws if not)
- Increments
CurrentValue - Increments
NextValue - Formats the new
CurrentValueaccording toFormat - 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
formatprovided, must contain '#' - If
currentValueprovided, must be non-negative - If
currentValuechanged,NextValueis recalculated ascurrentValue + 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:
- Admin configures LedgerJournalName, selects a NumberSequence
- When user creates journal, voucher numbers are generated from the referenced sequence
- 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:
- Resolve ModuleFeature to NumberSequence
- Acquire pessimistic lock via
GetByIdForUpdateAsync() - Call domain method
GetNextNumber() - 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:
-
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! -
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 -
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:
- Accept gaps (most common in modern systems)
- Implement sequence reservation system (complex)
- Use database sequences with NOCACHE option (PostgreSQL-specific)
Related Documentation
Domain Aggregates
- LedgerJournalName Aggregate - Uses NumberSequence for voucher generation
- LedgerJournalHeader Aggregate - Consumer of generated numbers
Conceptual Documentation
- Number Sequence Management - Complete workflow and patterns
API Documentation
- NumberSequence API - REST endpoints for sequence management
Cross-Module References
- Journal Configuration and Setup - Integration with journal system
Last Updated: 2025-01-15 | Version: 1.0 | Status: Production