Modern Accounting Ledger Platform Design

Context Diagram

1. System Design

High-Level Design

Container Diagram

Architecture Choice: Microservices

For a system with requirements for high scalability (thousands of transactions per second), multi-tenancy, and modularity, a Microservices Architecture is the most suitable choice.

Why Microservices?

A monolith would become a single point of failure and a significant bottleneck at the required scale. A modular monolith might be a starting point, but designing for microservices from the outset will prevent costly re-architecting later.

Components

Core Services

Transaction Service

Transaction Service

Journal Service

Journal Service

Balance Service

Balance Service

Reporting Service

Reporting Service

Support Services

Audit Service

Audit Service

Account Service

Account Service

Tenant Service

Tenant Service

Eventual Consistency vs. Strong Consistency

We will use a hybrid approach to balance financial integrity and performance:

Strong Consistency for the Write Path (The Source of Truth)

Eventual Consistency for the Read Path (Reporting & Analytics)

In summary: We achieve the perfect balance. Financial integrity is guaranteed with strong consistency at the core, while performance and scalability are enabled by leveraging eventual consistency for reporting.


2. Database Design

ER Diagram

Database Tables

1. tenants

Purpose: Represents a single, isolated client or company using the platform.

Analogy: A tenant is like a separate, locked filing cabinet. Everything for one company (their accounts, their transactions) is inside their cabinet, and no other company can see into it.

Key Attributes:

Role in the System: The tenants table is the root of the entire multi-tenancy model. Every other piece of data in the system must be linked back to a specific tenant.

2. accounts (Chart of Accounts)

Purpose: Defines the complete list of financial "buckets" where a tenant can record value. It's the structured list of everything the company owns (Assets), owes (Liabilities), its net worth (Equity), what it earns (Revenue), and what it spends (Expenses).

Analogy: If the tenant is a filing cabinet, the Chart of Accounts is the set of labeled folders inside that cabinet (e.g., "Cash," "Office Supplies," "Sales").

Key Attributes:

Role in the System: It provides the foundational structure for all accounting. No financial entry can be made without specifying which account it belongs to.

3. transactions

Purpose: Represents a high-level business event that causes one or more financial changes. It acts as a container or a grouping mechanism for related journal entries.

Analogy: A transaction is a single business activity, like a "Weekly Sales Summary" or a "Payroll Run for November." This single activity might have multiple distinct accounting impacts.

Key Attributes:

Role in the System: It provides context and traceability, linking multiple, granular journal entries back to a single, understandable business cause.

4. journals

Purpose: Represents a single, complete, and balanced journal entry. This is the core, atomic unit of accounting in the double-entry system. It is the formal record of a financial event.

Analogy: A journal is one specific accounting record within the "Payroll Run for November" transaction. For example, the journal entry for "Recording Salary Expenses" or the separate journal entry for "Recording Payroll Tax Liabilities."

Key Attributes:

Role in the System: This is the primary auditable entity. Every record in this table must correspond to a set of entries whose debits and credits are perfectly balanced.

5. entries

Purpose: This is the immutable general ledger. It represents the most granular level of detail: the individual debit and credit lines that make up a journal. All monetary amounts are stored as BIGINT integers in the smallest currency unit (e.g., cents for USD, pence for GBP) to prevent floating-point rounding errors.

Analogy: An entry is a single line item within a journal entry. For example, "Debit Cash account by $500" or "Credit Sales Revenue account by $500."

Key Attributes:

Role in the System: This is the ultimate source of truth for all financial calculations. The balance of any account, at any point in time, can be calculated by summing all the entries for that account. This table is append-only; entries are never updated or deleted.

Core Implementation Details

How You Handle Double-Entry Integrity

Double-entry integrity—the rule that for any given journal entry, the total debits must equal the total credits—is enforced at the Application Service Layer before any data is committed to the database.

The responsibility lies with the Journal Service.

The Process:

  1. Receive Request: The Journal Service consumes an event or receives a request to create a new journal. This request includes the journal's metadata and a list of its proposed entries (the debit/credit lines).

  2. In-Memory Validation: Before touching the database, the service performs an in-memory calculation:

  3. Integrity Check: The service then asserts two conditions:

  4. Commit or Reject:

This application-level enforcement is the standard and most robust pattern, as this kind of multi-row, cross-table business rule cannot be effectively modeled with simple database constraints.

How Balances Are Calculated (On Read vs Precomputed)

We use a pre-computed strategy to ensure high performance for reporting. Balances are not calculated on-read for standard reports.

The Process:

  1. Write and Notify: After the Journal Service successfully commits a new journal and its entries to the Primary DB, it publishes a JournalPosted event to the Event Bus (Kafka).

  2. Pre-computation: The dedicated Balance Service consumes this event. For each entry in the event, it performs a transactional read-modify-write operation on the balances summary table (also in the Primary DB). It places a lock on the specific account's balance row (SELECT FOR UPDATE), calculates the new balance, and saves it. This is the "pre-computation" step.

  3. Replication: The database's streaming replication automatically copies the newly updated balance from the Primary DB to the Read Replica DB.

  4. Fast Reads: The Reporting Service executes its queries against the Read Replica. To get an account's balance, it performs a simple, lightning-fast query:

SELECT balance
FROM balances
WHERE account_id = ?;

This CQRS approach ensures that the heavy calculation work is done once on the write path, making the read path (reporting) extremely fast and scalable.

How Multi-Tenancy Is Enforced

Multi-tenancy is enforced using a robust, multi-layered strategy centered around a shared-database, shared-schema model with Row-Level Security.

  1. Schema Foundation (Logical Separation): Every table that contains tenant-specific data (accounts, transactions, journals, entries, balances) has a mandatory, non-nullable tenant_id column. This foreign key links every piece of data to its owner.

  2. Application Layer Enforcement: The data access layer (DAL) in every microservice is programmed to always include a WHERE tenant_id = ? clause in every SQL query. The current tenant_id is derived from the user's authenticated session (e.g., from a JWT). This prevents accidental data leakage in the application code.

  3. Database Layer Enforcement (The Ultimate Safeguard): We use PostgreSQL's Row-Level Security (RLS):

We do not use a schema-per-tenant or database-per-tenant model, as those approaches are much harder to manage and scale to thousands of clients.

Currency and Time Zone Considerations

Our design handles these critical aspects with established best practices to ensure accuracy and prevent ambiguity.

Currency:

Time Zone:


3. Design & Code Standards

Key Engineering Standards (Java/Spring Boot Focus)

1. API Design & Project Structure

Consistency is paramount. We will adopt a standardized structure for all Spring Boot microservices.

API Versioning
Naming Conventions
# In application.yml
spring:
  jackson:
    property-naming-strategy: SNAKE_CASE
Folder Structure (Spring Boot Standard Package-by-Feature)

We will use the standard Maven/Gradle layout (src/main/java, src/main/resources, etc.) combined with a "package-by-feature" approach for clarity. The root package will be com.company.serviceName.

com.company.journalService
|-- Application.java
|
|-- api
|   |-- V1
|   |   |-- JournalController.java
|   |   |-- dto
|   |   |   |-- CreateJournalRequest.java
|   |   |   +-- JournalResponse.java
|
|-- domain
|   |-- Journal.java
|   |-- Entry.java
|   +-- JournalRepository.java
|
|-- service
|   |-- JournalService.java
|   |-- impl
|   |   +-- JournalServiceImpl.java
|
|-- event
|   |-- KafkaProducer.java
|   |-- KafkaConsumer.java
|   |-- dto
|   |   +-- JournalPostedEvent.java
|
|-- config
|   |-- KafkaConfig.java
|   |-- AppConfig.java
|
+-- exception
    |-- GlobalExceptionHandler.java
    +-- UnbalancedJournalException.java

2. Configuration Management

We will adhere strictly to Spring Boot's externalized configuration principles.

3. Error Handling, Idempotency & Resiliency

4. Security

Encryption
Audit Trails
Access Control (RBAC)

4. Code Review Guidelines

Code Review Checklist

Correctness & Data Integrity

Security & Auditing

Performance & Scalability

Maintainability & Observability


5. Problem-Solving & Trade-Off Handling

Backdated Transactions: How Would We Handle Late Entries That Change Historical Balances?

This is a critical accounting requirement that clashes with the goal of immutable, pre-computed balances. The key is to never change the past, but to correctly represent it.

The Immutable Approach

A "backdated" transaction is not an edit of history. It is a new transaction created today (posted_at = now) with an effective date in the past (effective_at = a past date). This maintains a perfect, immutable audit trail.

The challenge is correcting balances for reporting. Simply adding the backdated amount to the current balance is incorrect, as it misrepresents historical reports.

Solution: The Recalculation and Snapshot Strategy

  1. Data Model: The transactions table must have both posted_at (when it was recorded, as TIMESTAMPTZ) and effective_at (the business date it applies to, as TIMESTAMPTZ).

  2. Balance Correction:

  3. Handling Historical Reporting: We cannot afford to recalculate reports from the beginning of time for every request. We use a snapshot and replay mechanism:

    Periodic Snapshots: The system will generate and store an end_of_day_balances snapshot for every account, every day.

    CREATE TABLE end_of_day_balances (
        account_id UUID,
        balance_date DATE,
        end_of_day_balance BIGINT,
        PRIMARY KEY (account_id, balance_date)
    );
    

    Generating a Historical Report (e.g., balance for Nov 15th):

    1. The Reporting Engine first looks for an exact snapshot in end_of_day_balances for that account and date
    2. If not found, it finds the most recent snapshot before the requested date (e.g., Nov 14th)
    3. It then queries the immutable entries table for all entries where effective_at is between the snapshot date and the requested date
    4. It calculates the final balance by applying these entries to the snapshot balance
  4. (Optional) Handling Backdated Entries: When a transaction with a past effective_at is posted, we must invalidate affected snapshots. We can run an asynchronous job that finds the first affected snapshot (the one on the effective_at date) and recalculates all subsequent daily snapshots up to the present day. This is a heavy operation but runs in the background and ensures future reports are fast and correct.

This approach honors immutability and auditability while providing a mechanism for accurate historical reporting, trading off some background processing cost for fast reads.

Audit & Compliance: Designing for Detailed Audit Trails Without Bloating Storage

The Challenge

Financial systems operate under strict regulatory scrutiny (e.g., SOX, SEC rules), demanding a complete, verifiable, and tamper-resistant trail of every significant action. A naive approach of logging everything to a primary database would cause massive storage bloat, degrade performance, and fail to meet compliance standards for data immutability.

Our design must produce a detailed audit trail that is:

  1. Complete: Captures every financial event and significant state change (e.g., account creation, permission changes)
  2. Immutable: Cannot be altered or deleted, even by system administrators
  3. Verifiable: Provides a "chain of custody" to prove its integrity
  4. Cost-Effective: Can be stored for long periods (e.g., 7-10 years) without incurring prohibitive costs

The Solution: A Decoupled Audit Pipeline with Immutable Storage

Our solution is to treat auditing not as a feature of other services, but as a dedicated, first-class system component with its own data pipeline.

A. The Event Bus as the Source of Truth

The foundation of our audit strategy is the Event Bus (Apache Kafka). We establish a convention that any service performing a significant action must publish a corresponding event.

These events are structured, versioned (e.g., using Avro/Protobuf schemas), and contain rich context: what happened, who did it (user_id), when it happened (timestamp as TIMESTAMPTZ), and from where (source_ip). This event stream is our raw source of audit data.

B. The Dedicated Audit Service

We have a dedicated, standalone Audit Service. This service has one, and only one, purpose: to consume events from Kafka and persist them to a secure, long-term store.

C. Immutable Storage (The Key to Compliance and Cost Savings)

The Audit Service does not write to our primary PostgreSQL database. It writes to a dedicated Object Storage System (like AWS S3) configured for maximum security and compliance.

How it Works without Bloating Storage:

How it Ensures Immutability and Verifiability:

D. Querying the Audit Trail

For compliance checks or investigations, we use a "schema-on-read" query engine like AWS Athena. Athena can run standard SQL queries directly on the compressed files in S3 without needing to load them into a database. This provides powerful query capabilities on our long-term, low-cost audit archive.

Trade-Offs and Conclusion

This design makes a deliberate trade-off:

This is the gold standard for modern audit and compliance design.


6. Reporting Strategy

Our reporting strategy is built on the CQRS (Command Query Responsibility Segregation) pattern. We have made a fundamental architectural decision to physically separate the data structures used for writing transactions (the normalized entries table) from the data structures used for reading and reporting (the pre-computed balances and end_of_day_balances tables). This separation is the key to achieving high performance.

How We Keep Reporting Fast (The Core Principles)

  1. Pre-computation: We do the heavy lifting upfront. The Balance Service consumes events and continuously updates summary tables (balances, end_of_day_balances). Instead of calculating a sum over millions of rows at report time, we read a single, pre-calculated value.

  2. Dedicated Read Replicas: All reporting queries are directed to a Read Replica of our PostgreSQL database. This ensures that even a complex, ad-hoc report will never block or slow down the critical write path on the Primary DB where new transactions are being recorded.

  3. Dedicated Analytics Database: For the most complex, non-standard, or resource-intensive queries, we offload the work entirely to a dedicated Analytics DB (ClickHouse). This columnar database is specifically designed to scan billions of rows in seconds, providing powerful ad-hoc capabilities without touching the operational database cluster at all.

  4. Strategic Caching: For extremely common reports (e.g., last month's income statement), the final JSON payload can be cached in an in-memory store like Redis. This allows us to serve the report in milliseconds without hitting any database.

Supporting Specific Report Types

1. Trial Balance

What it is: A list of every account in the Chart of Accounts and its final debit or credit balance at a specific point in time. The purpose is to verify that total debits equal total credits across the entire system.

How we support it: This is the most direct beneficiary of our pre-computed balances table.

  1. The Reporting Service receives a request, typically for the current moment
  2. It executes a simple, fast JOIN query against the Read Replica:
SELECT a.account_code, a.name, a.type, b.balance
FROM accounts a
JOIN balances b ON a.account_id = b.account_id
WHERE a.tenant_id = ? AND a.is_active = TRUE;
  1. The service then formats this list, placing positive balances for Asset/Expense accounts in the "Debit" column and positive balances for Liability/Equity/Revenue accounts in the "Credit" column

Performance: This report is generated almost instantly, regardless of the number of transactions, because it only reads the final summary data.

2. Balance Sheet & Income Statement

What they are: These are not new queries; they are specific presentations of the Trial Balance data.

How we support them:

  1. The Reporting Service starts by fetching the Trial Balance data using the exact same fast query as above
  2. The Report Formatter component then applies business logic to this data set
  3. For a Balance Sheet, it filters and groups the accounts based on their type (ASSET, LIABILITY, EQUITY)
  4. For an Income Statement, it filters and groups accounts based on their type (REVENUE, EXPENSE)
  5. It calculates the necessary subtotals and totals (e.g., "Total Current Assets," "Net Income") and arranges the data into the standard financial statement format

Performance: Like the Trial Balance, these reports are extremely fast because the underlying data retrieval is from the pre-computed balances table. The formatting is a quick in-memory operation.

3. Custom Date-Range Reports

What it is: A detailed list of all individual transactions that occurred for a specific account (or set of accounts) within a given date range (e.g., "Show me all entries for the 'Office Supplies' account from Oct 1 to Oct 31").

How we support it: This is the one type of report that cannot be served from our summary tables. It requires querying the raw ledger data. This is where our offloading strategy is critical.

  1. The Reporting Service identifies this as a transactional query
  2. It directs the query to the Analytics DB (ClickHouse), which contains a replica of all entries
  3. It executes a query that is highly efficient in a columnar database:
SELECT effective_at, description, direction, amount
FROM entries
WHERE account_id = ?
  AND effective_at BETWEEN '2023-10-01' AND '2023-10-31'
ORDER BY effective_at;

Performance: While more intensive than a balance lookup, this query is still very fast. Columnar databases like ClickHouse are optimized for this exact type of query—filtering on a few columns (account_id, effective_at) and retrieving data from others over a large number of rows. This keeps the operational PostgreSQL cluster completely free from this heavy analytical workload.

4. Time-Based Snapshots for Historical Views

What it is: The ability to see a Trial Balance, Balance Sheet, or Income Statement as it stood at a specific point in the past (e.g., "What was our Balance Sheet at the close of business last quarter?").

How we support it: We use a dedicated snapshot table, populated by a periodic batch job.

  1. end_of_day_balances Table: As described in the backdating problem, a nightly job calculates and stores the definitive end-of-day balance for every account.

  2. When a user requests a historical report for a date YYYY-MM-DD, the Reporting Service queries this snapshot table instead of the live balances table:

SELECT a.account_code, a.name, a.type, dbs.balance
FROM accounts a
JOIN end_of_day_balances dbs ON a.account_id = dbs.account_id
WHERE a.tenant_id = ? AND dbs.snapshot_date = 'YYYY-MM-DD';
  1. This provides a fast, accurate baseline of the historical state

  2. To account for any backdated transactions posted after the snapshot was taken, the service runs a small, secondary query on the entries table to find and apply any adjustments (as detailed in the backdating solution)

Performance: This is extremely fast. The query hits a simple, indexed snapshot table. The adjustment query, if needed, is over a very small and recent subset of data, ensuring that generating historical reports does not require a full ledger scan.