انتقل إلى المحتوى الرئيسي

Number Sequence Management

File: /docs/concepts/shared/number-sequence-management.concept.md
Audience: System Configurators, Architects, Developers
Scope: Cross-Module (Shared Infrastructure)


Overview

This document explains the complete number sequence management system in the ERP, from initial configuration through number generation and lifecycle management. Number sequences are critical infrastructure that ensure unique, auditable identifiers for business documents across all modules.

Business Goal

Enable organizations to:

  • Generate consistent, gap-minimal sequential numbers for business documents
  • Maintain audit trails through traceable document numbering
  • Support regulatory compliance with continuous numbering requirements
  • Configure format patterns that match organizational standards
  • Prevent duplicate identifiers across concurrent operations

Key Concepts

ConceptDefinitionExample
NumberSequenceConfiguration that generates formatted sequential numbers"Sales Invoice Numbers"
Format PatternTemplate with '#' placeholders defining number structure"INV-2025-####"
ModuleFeatureSpecific transaction type that uses a sequenceSalesInvoices, PurchaseOrders
Sequence ReferenceAssociation between a sequence and ModuleFeature(s)SalesInvoices → "Sales Invoice Numbers"
Current ValueLast generated number42
Pessimistic LockDatabase row lock preventing concurrent accessFOR UPDATE in SQL

High-Level Architecture


Complete Workflow


Detailed Process Steps

Phase 1: Initial Configuration (Administrator)

Step 1.1: Create Number Sequence

Prerequisites: Administrator access to system configuration

Configuration Decisions:

  1. Name (Unique Identifier)

    • Purpose: Human-readable name for administration
    • Example: "Sales Invoice Numbers", "Purchase Order Sequence"
    • Must be unique across entire system
  2. Format Pattern (Number Structure)

    • Uses '#' as digit placeholder
    • All other characters are literals
    • Examples:
      "INV-####"          → INV-0001, INV-0002, ...
      "PO-2025-######" → PO-2025-000001, PO-2025-000002, ...
      "V-####-A" → V-0001-A, V-0002-A, ...
  3. Starting Value (Optional)

    • Default: 0 (first number will be 1)
    • Can be set to resume from legacy system
    • Example: Start at 1000 to continue from old system
  4. ModuleFeature Associations

    • Select which transaction types use this sequence
    • Examples:
      • Sales Invoices → "Sales Invoice Numbers"
      • Sales Invoice Vouchers → "Sales Invoice Numbers"
      • Return Invoices → "Sales Invoice Numbers"

Real-World Example:

{
"name": "Sales Invoice Master Sequence",
"format": "SI-2025-#####",
"startingValue": 0,
"moduleFeatures": [
"SalesInvoices",
"SalesInvoiceVoucher",
"ReturnInvoices"
]
}

Why Multiple Features Use Same Sequence:

  • Provides unified numbering across related documents
  • Simplifies audit trail (chronological order across document types)
  • Reduces administrative overhead

Step 1.2: Understanding ModuleFeatures

ModuleFeature Hierarchy:

Module: GeneralLedger (ID range: 1-100)
- JournalBatchNumber (1)
- TraceNumber (2)
- GeneralJournalReversalVoucher (4)

Module: AccountsReceivable (ID range: 101-200)
- Customers (101)
- SalesInvoices (102)
- SalesInvoiceVoucher (103)
- CustomerPaymentJournal (104)
- PrepaymentInvoice (105)
- ReturnInvoices (108)

Module: AccountsPayable (ID range: 201-300)
- Vendors (201)
- PurchaseInvoices (202)
- PurchaseInvoiceVoucher (203)
- VendorPaymentJournal (204)
- PrepaymentInvoiceVoucher (206)

Configuration Pattern:

// One sequence can serve multiple related features
var invoiceSequence = new NumberSequence("Invoice Numbers", "INV-####");
invoiceSequence.AddReference(AccountsReceivableFeature.SalesInvoices);
invoiceSequence.AddReference(AccountsReceivableFeature.SalesInvoiceVoucher);

// Or, separate sequences for complete isolation
var salesSequence = new NumberSequence("Sales Invoices", "SI-####");
var returnSequence = new NumberSequence("Return Invoices", "RI-####");

Step 1.3: Format Pattern Design Guidelines

Best Practices:

  1. Include Year for Long-Running Systems

    "INV-2025-####"  // Better than "INV-####" if system runs multiple years
  2. Plan for Volume

    "####"     // Supports 0-9999 (10K documents)
    "#####" // Supports 0-99999 (100K documents)
    "######" // Supports 0-999999 (1M documents)
  3. Use Meaningful Prefixes

    "SI-####"  // Sales Invoice
    "PI-####" // Purchase Invoice
    "PAY-####" // Payment
    "ADJ-####" // Adjustment
  4. Consider Sorting Requirements

    // Good: Alphabetically sorts chronologically
    "INV-2025-####"

    // Bad: Won't sort properly across years
    "INV-####-2025"
  5. Avoid Special Characters that Cause Issues

    // Good
    "INV-####"
    "PO_####"

    // Potentially problematic in some systems
    "INV/####" // Slash can be confused with URL paths
    "INV#####" // No separator between prefix and number

Phase 2: Number Generation (Runtime Operations)

Step 2.1: Request Flow

Trigger Points: A command handler needs to create a business document that requires a unique identifier.

Decision Point: How to identify the correct sequence?

Option 1: By ModuleFeature (Most Common)

// Handler knows the transaction type
string invoiceNumber = await _numberSequenceService.GetNextNumberAsync(
AccountsReceivableFeature.SalesInvoices
);

Option 2: By NumberSequence ID (When Pre-Configured)

// Journal has pre-configured voucher sequence
string voucherNumber = await _numberSequenceService.GetNextNumberAsync(
journal.VoucherSeriesId // Guid of NumberSequence
);

Step 2.2: The Critical Locking Pattern

The Problem: Race Condition

Transaction A                    Transaction B
----------- -----------
Read CurrentValue: 42 Read CurrentValue: 42
Increment to: 43 Increment to: 43
Save: CurrentValue = 43 Save: CurrentValue = 43
Generate: "INV-0043" Generate: "INV-0043"

RESULT: DUPLICATE NUMBERS!

The Solution: Pessimistic Row Lock

-- Transaction A acquires lock first
SELECT * FROM number_sequences WHERE id = $1 FOR UPDATE;
-- Transaction B must wait here until A commits

-- Transaction A increments and commits
UPDATE number_sequences SET current_value = 43 WHERE id = $1;
COMMIT;
-- Lock released

-- Transaction B can now proceed
SELECT * FROM number_sequences WHERE id = $1 FOR UPDATE;
-- Gets CurrentValue = 43 (updated by A)
-- Increments to 44
-- No duplicate!

Implementation:

// Repository Layer (PostgreSQL-specific)
public async Task<NumberSequence?> GetByIdForUpdateAsync(Guid id)
{
return await _context.NumberSequences
.FromSqlRaw(
"SELECT * FROM general_ledger.number_sequences WHERE id = {0} FOR UPDATE",
id
)
.FirstOrDefaultAsync();
}

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

// Safe to increment - we have exclusive access
string number = sequence.GetNextNumber();

// Mark for update
_repository.Update(sequence);

// Caller's transaction will commit and release lock
return number;
}

Step 2.3: Transaction Coordination

Command Handler Pattern:

public class CreateSalesInvoiceCommandHandler : IRequestHandler<CreateSalesInvoiceCommand, Guid>
{
private readonly INumberSequenceService _numberSequenceService;
private readonly ISalesInvoiceRepository _invoiceRepository;
private readonly IUnitOfWork _unitOfWork;

public async Task<Guid> Handle(CreateSalesInvoiceCommand request, CancellationToken ct)
{
// IMPORTANT: Number generation happens INSIDE the transaction
// that creates the invoice

// Step 1: Generate invoice number (acquires lock)
string invoiceNumber = await _numberSequenceService.GetNextNumberAsync(
AccountsReceivableFeature.SalesInvoices
);

// Step 2: Create invoice with generated number
var invoice = new SalesInvoice(
invoiceNumber: invoiceNumber,
customerId: request.CustomerId,
invoiceDate: request.InvoiceDate,
dueDate: request.DueDate
);

// Add lines, calculate totals, etc.
foreach (var lineDto in request.Lines)
{
invoice.AddLine(lineDto.ItemId, lineDto.Quantity, lineDto.Price);
}

// Step 3: Persist invoice
_invoiceRepository.Add(invoice);

// Step 4: Commit transaction (saves both sequence and invoice)
await _unitOfWork.SaveChangesAsync(ct);

// Lock is released here
// If any step failed, entire transaction rolls back
// including the sequence increment

return invoice.Id;
}
}

Transaction Rollback Scenario:

BEGIN TRANSACTION
1. Get next number: "INV-0042" (sequence locked, CurrentValue now 42)
2. Create invoice with "INV-0042"
3. Validate invoice business rules
4. Validation FAILS
ROLLBACK

Result:
- Invoice not created
- Sequence CurrentValue rolled back to 41
- Next invoice will get "INV-0042" (no gap)

Exception Scenario (Gap Created):

BEGIN TRANSACTION
1. Get next number: "INV-0042" (CurrentValue: 42)
2. Create invoice with "INV-0042"
3. COMMIT (sequence saved)
NEXT TRANSACTION
4. User cancels operation or network error

Result:
- Sequence CurrentValue is 42 (committed)
- No invoice exists with "INV-0042"
- Next invoice will get "INV-0043" (gap at 0042)

Architectural Decision: Most systems accept occasional gaps for simplicity. True gap-free numbering requires complex two-phase commit patterns.


Phase 3: Maintenance and Lifecycle Management

Step 3.1: Deactivating a Sequence

Use Case: Temporarily disable number generation without deleting configuration.

Scenario: End of fiscal year, switching to new year-specific sequence.

// Deactivate old sequence
var old2024Sequence = await _repository.GetByNameAsync("Invoices-2024");
old2024Sequence.Deactivate();
_repository.Update(old2024Sequence);

// Activate new sequence
var new2025Sequence = await _repository.GetByNameAsync("Invoices-2025");
new2025Sequence.Activate();
_repository.Update(new2025Sequence);

// Reassign ModuleFeature
await UpdateModuleFeatureSequenceAsync(
moduleFeature: AccountsReceivableFeature.SalesInvoices,
newSequenceId: new2025Sequence.Id
);

await _unitOfWork.SaveChangesAsync();

User Impact:

  • Attempting to generate from deactivated sequence throws exception
  • Historical documents retain their numbers (immutable)
  • Audit trail preserved

Step 3.2: Changing Format

Use Case: Company rebranding or numbering standard change.

Important: Format changes only affect FUTURE numbers, not historical ones.

var sequence = await _repository.GetByIdAsync(sequenceId);

// Current: "INV-####", CurrentValue: 42, Last generated: "INV-0042"

sequence.Update(format: "INVOICE-2025-#####");

// Next number: "INVOICE-2025-00043"
// Historical "INV-0042" remains unchanged in database

Best Practice: When changing formats significantly, create a NEW sequence instead of updating existing one to avoid confusion.

Step 3.3: Reassigning ModuleFeatures

Use Case: Consolidating sequences or separating previously combined ones.

Scenario: Initially, all invoices used one sequence. Now, company wants separate sequences for domestic vs. export invoices.

// Step 1: Create new sequences
var domesticSeq = new NumberSequence("Domestic Invoices", "DOM-####");
var exportSeq = new NumberSequence("Export Invoices", "EXP-####");

_repository.Add(domesticSeq);
_repository.Add(exportSeq);
await _unitOfWork.SaveChangesAsync();

// Step 2: Get current unified sequence
var oldUnifiedSeq = await _repository.GetByDataTypeAsync(
AccountsReceivableFeature.SalesInvoices
);

// Step 3: Remove reference from old sequence
oldUnifiedSeq.RemoveReference(AccountsReceivableFeature.SalesInvoices);
_repository.Update(oldUnifiedSeq);

// Step 4: Add references to new sequences
// (This requires application logic to determine which sequence to use)
// Typically done through configuration or document type field

await _unitOfWork.SaveChangesAsync();

Migration Considerations:

  • Historical documents keep their original numbers
  • New documents use new sequences
  • Application logic may need updates to select correct sequence
  • Report queries may need adjustments to handle multiple sequences

Step 3.4: Resetting or Advancing a Sequence

Use Case 1: Annual Reset

var sequence = await _repository.GetByIdAsync(sequenceId);

// Reset to 0 (next number will be 1)
sequence.Update(currentValue: 0);
_repository.Update(sequence);
await _unitOfWork.SaveChangesAsync();

Use Case 2: Migrating from Legacy System

var sequence = await _repository.GetByIdAsync(sequenceId);

// Legacy system last invoice was SI-2024-05432
// Set current value to continue from there
sequence.Update(currentValue: 5432);
_repository.Update(sequence);
await _unitOfWork.SaveChangesAsync();

// Next generated number: "SI-2025-05433"

Common Configuration Patterns

Scenario: All sales-related documents use unified numbering for chronological traceability.

var salesSequence = new NumberSequence("Sales Documents", "SD-2025-#####");

salesSequence.AddReference(AccountsReceivableFeature.SalesInvoices);
salesSequence.AddReference(AccountsReceivableFeature.ReturnInvoices);
salesSequence.AddReference(AccountsReceivableFeature.PrepaymentInvoice);

Result:

SD-2025-00001  - Sales Invoice
SD-2025-00002 - Sales Invoice
SD-2025-00003 - Return Invoice
SD-2025-00004 - Prepayment Invoice
SD-2025-00005 - Sales Invoice

Benefits:

  • Chronological order across document types
  • Single sequence to manage
  • Clear audit trail

Drawbacks:

  • Cannot distinguish document type from number alone
  • Single point of contention (all documents lock same sequence)

Pattern 2: Separate Sequences by Document Type

Scenario: Each document type has its own numbering for clear identification.

var salesInvSeq = new NumberSequence("Sales Invoices", "SI-2025-####");
salesInvSeq.AddReference(AccountsReceivableFeature.SalesInvoices);

var returnInvSeq = new NumberSequence("Return Invoices", "RI-2025-####");
returnInvSeq.AddReference(AccountsReceivableFeature.ReturnInvoices);

var prepaymentSeq = new NumberSequence("Prepayments", "PP-2025-####");
prepaymentSeq.AddReference(AccountsReceivableFeature.PrepaymentInvoice);

Result:

SI-2025-0001  - Sales Invoice
SI-2025-0002 - Sales Invoice
RI-2025-0001 - Return Invoice (separate sequence)
PP-2025-0001 - Prepayment (separate sequence)
SI-2025-0003 - Sales Invoice

Benefits:

  • Document type immediately visible in number
  • Reduced lock contention (separate sequences)
  • Easier filtering and reporting

Drawbacks:

  • More sequences to manage
  • Chronological order not immediately apparent across types

Pattern 3: Department-Specific Sequences

Scenario: Large organization with multiple departments issuing invoices independently.

var salesDeptSeq = new NumberSequence("Sales Dept Invoices", "SALES-####");
var serviceDeptSeq = new NumberSequence("Service Dept Invoices", "SVC-####");
var exportDeptSeq = new NumberSequence("Export Dept Invoices", "EXP-####");

// Application logic determines which sequence based on department
// This may require custom configuration beyond ModuleFeature

Benefits:

  • Departmental autonomy
  • Parallel processing (no cross-department contention)
  • Clear departmental identification

Drawbacks:

  • Requires additional application logic
  • More complex configuration

Pattern 4: Year-Specific Sequences

Scenario: Annual reset for regulatory or organizational standards.

// End of 2024
var seq2024 = new NumberSequence("Invoices 2024", "INV-2024-####");
seq2024.Deactivate(); // Stop using

// Start of 2025
var seq2025 = new NumberSequence("Invoices 2025", "INV-2025-####");
seq2025.AddReference(AccountsReceivableFeature.SalesInvoices);

Benefits:

  • Clean annual restart
  • Year embedded in document number
  • Historical sequences preserved

Drawbacks:

  • Annual maintenance required
  • Need to switch configuration at year boundary

Design Decisions & Rationale

Decision 1: Format Pattern with '#' Placeholders

Alternative Considered: Complex format DSL with variables like {YEAR}, {SEQUENCE}, {PREFIX}.

Decision: Use simple '#' placeholders.

Rationale:

  • Easy to understand for administrators
  • Covers 95% of real-world requirements
  • Less prone to configuration errors
  • Year/prefix are typically static per sequence

Trade-off: Dynamic values (like auto-updating year) require creating new sequences or application-level logic.

Decision 2: Pessimistic Locking (FOR UPDATE)

Alternative Considered: Optimistic locking with version checks.

Decision: Use pessimistic row-level locks.

Rationale:

  • Guarantees no duplicates
  • Simpler error handling (no retry logic)
  • Acceptable performance for typical ERP workloads
  • Standard pattern in established ERP systems

Trade-off: Lock contention under very high concurrency. Mitigated by using separate sequences.

Decision 3: Accept Gaps on Transaction Rollback

Alternative Considered: Two-phase commit to prevent all gaps.

Decision: Accept occasional gaps.

Rationale:

  • Vastly simpler implementation
  • Acceptable for most regulatory requirements
  • True gap-free numbering is extremely complex
  • Gaps are rare in practice (most transactions succeed)

Trade-off: Small risk of gaps, but benefits of simplicity outweigh.

Decision 4: ModuleFeature as Enumeration (Not Configuration)

Alternative Considered: Store module features as database table.

Decision: Use code-based Enumeration pattern.

Rationale:

  • ModuleFeatures are tightly coupled to code
  • Adding a feature requires code changes anyway (new aggregate, handlers, etc.)
  • Type-safe in application code
  • No risk of invalid references

Trade-off: Less flexible for runtime configuration, but appropriate for this use case.


Performance & Scalability

Throughput Expectations

Single Sequence Performance (PostgreSQL, typical hardware):

  • Sequential operations: 500-1000 numbers/second
  • Bottleneck: Database lock duration
  • Latency: 5-15ms per generation (including lock wait)

Scaling Strategies:

  1. Horizontal Scaling via Sequence Separation

    One global sequence: 500 numbers/sec max
    Ten departmental sequences: 5000 numbers/sec aggregate
  2. Connection Pooling

    • Ensure adequate pool size (min: 20 connections)
    • Monitor for pool exhaustion under load
  3. Transaction Optimization

    • Minimize time holding lock
    • Generate number as late as possible in transaction
    • Avoid external calls while holding lock

Monitoring & Alerting

Key Metrics:

  1. Lock Wait Time

    -- Monitor transactions waiting for sequence locks
    SELECT * FROM pg_stat_activity
    WHERE wait_event = 'tuple'
    AND query LIKE '%number_sequences%';
  2. Generation Rate

    -- Track numbers generated per sequence per hour
    SELECT
    ns.name,
    COUNT(*) as numbers_generated,
    MAX(current_value) as current_value
    FROM number_sequences ns
    GROUP BY ns.name;
  3. Sequence Depletion Warnings

    -- Alert when sequence approaching max capacity
    SELECT
    name,
    format,
    current_value,
    (10 ^ (LENGTH(format) - LENGTH(REPLACE(format, '#', '')))) as max_value,
    (current_value * 100.0 / (10 ^ (LENGTH(format) - LENGTH(REPLACE(format, '#', ''))))) as percent_used
    FROM number_sequences
    WHERE percent_used > 80; -- Alert at 80% capacity

Troubleshooting

Issue 1: Slow Number Generation

Symptoms: CreateInvoice operations taking 500ms+ instead of <50ms.

Diagnosis:

-- Check for lock contention
SELECT
pid,
wait_event,
state,
query
FROM pg_stat_activity
WHERE wait_event = 'tuple'
AND query LIKE '%number_sequences%';

Solutions:

  1. Identify high-contention sequences
  2. Consider splitting into multiple sequences
  3. Review transaction sizes (are they unnecessarily long?)
  4. Increase database connection pool

Issue 2: Unexpected Gaps in Numbering

Symptoms: Generated numbers jump (e.g., INV-0042, INV-0044, missing INV-0043).

Diagnosis:

-- Find gaps in invoice numbering
WITH invoice_numbers AS (
SELECT
CAST(SUBSTRING(invoice_number FROM '\d+') AS INTEGER) as num
FROM sales_invoices
WHERE invoice_number LIKE 'INV-%'
)
SELECT
num + 1 as missing_number
FROM invoice_numbers
WHERE NOT EXISTS (
SELECT 1 FROM invoice_numbers in2
WHERE in2.num = invoice_numbers.num + 1
)
ORDER BY num;

Root Causes:

  1. Transaction rollback after number generation
  2. Application error after commit
  3. Manual deletion of invoices

Resolution: Typically acceptable. If critical, investigate specific missing numbers.

Issue 3: Format Doesn't Support Enough Numbers

Symptoms: current_value approaching maximum capacity.

Example: Format "INV-###" maxes at 999.

Solution:

// Update format to support more digits
var sequence = await _repository.GetByIdAsync(sequenceId);
sequence.Update(format: "INV-#####"); // Now supports 99,999
_repository.Update(sequence);
await _unitOfWork.SaveChangesAsync();

Domain Documentation

Conceptual Documentation

API Documentation


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