Checklist: Progressive SOA Decomposition (TDD)
This checklist provides a step-by-step, deep-dive process for decomposing Framework M Business modules (like WMS, People, and Finance) into Macroservices (SOA) while maintaining a standard monolith mode for “Indie” users.
Showcase Scope: This document covers the SOA decomposition of domain modules. Extremely high-throughput specialized microservices (e.g., Book-Keeper, Stock-Keeper built on Litestar/TigerBeetle) are considered External Adapters to this ecosystem.
Part 1: The Contract (Shared Protocols)
Section titled “Part 1: The Contract (Shared Protocols)”Goal: Define WHAT a module does, not HOW it does it. This avoids direct library imports.
- 1.1 Identify the Cross-Module Call
- Example:
WMScallsFinancefor ledger syncing (CustomerService). - Example:
FinancecallsCorefor party details.
- Example:
- 1.2 Create a Shared Protocol
- Place in
libs/<provider>/src/<provider>/interfaces/. - Define a Python
Protocol(interface). - Use only standard Python types or shared DTOs (no DocType imports if possible).
- Place in
- 1.3 Zero-Import Check
- Ensure the consumer (
WMS) does NOT import from the provider’sservicesordoctypesdirectly. It should only import theProtocol.
- Ensure the consumer (
Part 2: The “Indie” Adapter (Local Monolith)
Section titled “Part 2: The “Indie” Adapter (Local Monolith)”Goal: Logic for when everything runs in a single process.
- 2.1 Red Test: Local Adapter
- Create
libs/<provider>/tests/test_local_adapter.py. - Assert the adapter fulfills the protocol.
- Create
- 2.2 Green Code: Local Implementation
- Create
libs/<provider>/src/<provider>/adapters/local_<name>.py. - This implementation calls the local module’s services/controllers directly.
- Create
- 2.3 Refactor
- High coverage for the local path.
Part 3: The “Enterprise” Adapter (NATS/Remote)
Section titled “Part 3: The “Enterprise” Adapter (NATS/Remote)”Goal: Logic for when modules are separate processes.
- 3.1 Red Test: NATS Adapter
- Create
libs/<provider>/tests/test_nats_adapter.py. - Mock the NATS event bus.
- Assert that calling the protocol publishes a message to the correct subject.
- Create
- 3.2 Green Code: NATS Implementation
- Create
libs/<provider>/src/<provider>/adapters/nats_<name>.py. - Use
framework-m-standard’s NATS bridge. - Pattern A (Consistency): Use
bus.requestfor real-time lookups (e.g.FinancecallingCorefor Supplier terms). - Pattern B (CQRS): Use
bus.subscribefor local projections (e.g.WMSbuilding localItemread-models). - Scaling: Background subscribers MUST specify a
queuegroup name (e.g.,queue='wms-service') to enable NATS load-balancing across replicated pods.
- Create
- 3.3 Refactor
- Ensure timeouts and error handling for network calls are in place.
Part 4: Dependency Injection (Constructor Injection)
Section titled “Part 4: Dependency Injection (Constructor Injection)”Goal: Remove the “hidden” dependencies (singletons/direct imports).
- 4.1 Red Test: Service Isolation
- Run the consumer service (
MemberService) in a test that wipes the provider’s lib fromsys.modules. - It MUST fail with
ImportErrorif it still has a direct import.
- Run the consumer service (
- 4.2 Green Code: Refactor to DI
- Add the Protocol to the service’s
__init__. - Replace direct calls (e.g.,
bookkeeper_client.sync_account) withself.ledger.sync_account.
- Add the Protocol to the service’s
- 4.3 Container Wiring
- Register the service in the
Containerusingcontainer.register(...).
- Register the service in the
- 4.4 Container Wiring
- Register the service in the
Containerusingcontainer.register(...). - Ensure any environment-specific overrides (Indie vs Enterprise) are applied here.
- Register the service in the
Part 11: Identified Service Protocols (The Spoonfed List)
Section titled “Part 11: Identified Service Protocols (The Spoonfed List)”Goal: Explicit list of domain services to be abstracted into protocols for SOA decomposition.
11.1 Finance Domain Boundaries
Section titled “11.1 Finance Domain Boundaries”- ExternalLedgerProtocol: Primary interface for the external
BookKeeperClient(TigerBeetle). - BudgetProtocol:
- Service:
finance.services.budget_utilization - Methods:
update_budget_utilization(company, account, cost_center, fiscal_year, amount, reverse=False)
- Service:
- FiscalProtocol:
- Service:
finance.services.fiscal_year - Methods:
get_fiscal_year(date, company),is_period_closed(date, company)
- Service:
11.2 WMS Domain Boundaries
Section titled “11.2 WMS Domain Boundaries”- ExternalStockProtocol: Primary interface for the external
StockKeeperClient(Litestar/KurrentDB). - InventoryProtocol:
- Service:
wms.services.inventory_service - Methods:
get_stock_balance(item, warehouse),create_sle(...),get_bin(item, warehouse)
- Service:
- OnboardingProtocol:
- Service:
wms.services.product_onboarding_service - Methods:
submit_application(app),evaluate_pre_screening(...),record_screening(...),management_decide(...)
- Service:
- LifecycleProtocol:
- Service:
wms.services.outlet_lifecycle_service - Methods:
submit_registration(reg),cs_approve_registration(reg),agm_approve_registration(...),disburse_loan(loan, reference)
- Service:
- DemandProtocol:
- Service:
wms.services.demand_service - Methods:
calculate_predicted_demand(...),generate_distribution_center_demand(dc, from_date, to_date)
- Service:
Part 5: The “Mode” Toggle (The Switch)
Section titled “Part 5: The “Mode” Toggle (The Switch)”Goal: Toggle between Indie and Enterprise via configuration.
- 5.1 Red Test: Mode Switching
- Create
apps/business-m/tests/test_mode_toggle.py. - Set
os.environ["M_MODE"] = "enterprise". - Resolve the protocol from the container -> Assert it is the
NatsAdapter. - Set
os.environ["M_MODE"] = "indie". - Resolve -> Assert it is the
LocalAdapter.
- Create
- 5.2 Green Code: Container Logic
- Update
apps/business-m/src/business_m/container.py(or main init). - Add conditional bindings based on environment variables.
- Update
Part 6: The “Unified” Startup (Indie & Replicated Mode)
Section titled “Part 6: The “Unified” Startup (Indie & Replicated Mode)”Goal: Highly efficient “Golang-like” single-process startup that scales horizontally via replication.
- 6.1 Bootstrap Script (Unified Runner)
- Create
libs/<provider>/src/<provider>/main.py. - Use the
create_appfactory from Framework M. - Implement the Unified Async Loop to run API and Background Workers in the same process:
async def main():# 1. Start Background Listeners (Task)broker = get_broker()worker_task = asyncio.create_task(_run_worker_async(broker))# 2. Start Web Server (Blocking)app = create_app(serve_static=False)config = uvicorn.Config(app)server = uvicorn.Server(config)await server.serve()
- Create
- 6.2 Replication & Scaling Test
- Run 3 instances of the unified process.
- Send 3 NATS messages and 3 API requests.
- Success Criteria: API requests are distributed by the Ingress; NATS messages are distributed by the Queue Group.
- 6.3 Standalone Test
- Send a NATS message to an isolated provider -> Verify it executes.
Part 7: Final Verification
Section titled “Part 7: Final Verification”- 7.1 Indie Mode Smoke Test: Monolith works, ACID transactions hold.
- 7.2 Enterprise Mode Smoke Test: Services talk over NATS, logs show RPC calls.
Part 8: Protocol Spoonfeeding (The Blueprints)
Section titled “Part 8: Protocol Spoonfeeding (The Blueprints)”Goal: Concrete interfaces for WMS and Finance to be implemented as standalone services.
8.1 LedgerProtocol (Finance)
Section titled “8.1 LedgerProtocol (Finance)”Path: libs/finance/src/finance/interfaces/ledger.py
from typing import Protocol, Anyfrom datetime import datefrom decimal import Decimal
class LedgerProtocol(Protocol): async def sync_account(self, name: str, account_type: str, company: str, is_group: bool, parent_account: str | None) -> str: ... async def post_gl_entry(self, company: str, posting_date: date, debit_account: str, credit_account: str, amount: Decimal, narration: str, reference: str | None = None, cost_center: str | None = None, party_type: str | None = None, party: str | None = None) -> str: ... async def cancel_gl_entry(self, gl_entry_id: str) -> None: ... async def post_journal_entry(self, company: str, posting_date: date, entry_type: str, narration: str, legs: list[dict[str, Any]]) -> str: ... async def cancel_journal_entry(self, gl_batch_id: str) -> None: ... async def get_trial_balance(self, company: str, fiscal_year: str, as_of_date: date) -> list[Any]: ... async def post_period_closing(self, company: str, fiscal_year: str, closing_account: str, net_pl_amount: Decimal, posting_date: date) -> str: ... async def get_batch_gl_entries(self, gl_batch_id: str) -> list[Any]: ...8.2 StockProtocol (WMS)
Section titled “8.2 StockProtocol (WMS)”Path: libs/wms/src/wms/interfaces/stock.py
from typing import Protocol, Optionalfrom decimal import Decimal
class StockProtocol(Protocol): async def transfer_stock(self, tenant_id: str, item: str, from_zone: Optional[str], to_zone: Optional[str], qty: Decimal, rate: Decimal, reference: str) -> str: ... async def get_balance(self, tenant_id: str, item: str, zone: str) -> Decimal: ... async def get_ledger(self, tenant_id: str, item: str, zone: str, limit: int = 100) -> list[dict]: ...Part 9: Resilience (The Retry-Cooldown Loop)
Section titled “Part 9: Resilience (The Retry-Cooldown Loop)”Goal: Ensure network-bound adapters don’t fail on transient glitches.
- 9.1 Define Retry Logic
- Use exponential backoff (e.g., 1s, 2s, 4s).
- Target:
asyncio.TimeoutError,OSError,ConnectionError(transient NATS/network failures).
- 9.2 TDD: The “Green” Path with Failure
- Red Test: Mock a
TimeoutErrorfor the first 2 calls, then success. - Success Criteria: Service retries twice automatically, waits the cooldown, and eventually returns the success result.
- Red Test: Mock a
- 9.3 TDD: The “Hard” Failure
- Red Test: Mock
TimeoutErrorconsistently for all calls. - Success Criteria: Service exhausts retries and raises a clear
ResilienceError.
- Red Test: Mock
- 9.4 Implementation Strategy
- Implemented
@retry_with_backoffinlibs/finance/src/finance/utils/resilience.py. - Parallel copies in
libs/wms/src/wms/utils/resilience.pyandlibs/m_adapters/src/m_adapters/resilience.py. - Applied to all methods of all NATS adapters in
finance,wms, andm_adapters.
- Implemented
Part 10: Downstream Failure Handling
Section titled “Part 10: Downstream Failure Handling”Goal: Decide what happens when the remote service is DEAD, not just transiently slow.
- 10.1 Classify the Operation
- Critical (Synchronous): Payment Voucher, Stock Transfer. If Ledger/Stock-Keeper is down, the operation MUST fail.
- Non-Critical (Eventually Consistent): Account creation, Item onboarding. The record can be saved locally, but marked as “Pending Sync”.
- 10.2 Implement “Hard Fail” for Criticals
PaymentVoucher.on_submitandon_cancel:ResilienceError→ValidationError(rolls back DB transaction).StockEntry.on_submitandon_cancel:ResilienceError→ValidationError(rolls back DB transaction).
- 10.3 Implement “Soft Fail” for Non-Criticals
- Added
sync_statusfield (Synced, Pending, Error) toAccountandOutletDocTypes. Account.after_save: catchesResilienceErrorandBookKeeperSyncError, setssync_status="Pending", surfaces warning.Outlet.after_save: soft-fail stub withsync_status="Pending"pattern (NATS call wired in Part 12).
- Added
- 10.4 The Outbox Pattern (Enterprise Grade)
- Instead of calling the NATS adapter directly in the request cycle, save the intent to an
Outboxtable. - A background “Relay” service reads the outbox and retries the NATS call until success.
- Instead of calling the NATS adapter directly in the request cycle, save the intent to an
Part 16: External Microservice Integration
Section titled “Part 16: External Microservice Integration”Goal: Connect Framework M Macroservices to Specialized, high-performance external engines.
- 16.1 Define the External Protocol
- e.g.,
BookKeeperClientfor TigerBeetle. - These protocols are fundamentally different from internal SOA. They map to the external engine’s API, not a internal DocType.
- e.g.,
- 16.2 Implement Async Drivers
- Use high-performance Python drivers (e.g.,
msgspecfor serialization,uvloopif needed).
- Use high-performance Python drivers (e.g.,
- 16.3 Isolate the Interaction
- Interactions with these microservices should ideally be handled asynchronously (via Outbox) to prevent the framework’s synchronous handlers from blocking.
Part 12: The Enterprise Runner (Uvicorn Standalone)
Section titled “Part 12: The Enterprise Runner (Uvicorn Standalone)”Goal: Run a library as a self-contained microservice.
- 12.1 The App Wrapper
- Libraries (
libs/wms) are code, butapps/<service>are the executors. - They must fulfill the WebProtocol.
- Libraries (
- 12.2 Standalone Initialization
- In
app.py, initialize the framework:app = framework_m.init_app("<service_name>"). - The WebAdapter (located at
framework_m_standard/adapters/web/app.py) is used to implement theWebProtocol. - Bind Enterprise Adapters (NATS/GRPC) to the domain Protocols.
- In
- 12.3 Running via Uvicorn
- Command:
uvicorn <package>.app:app --host 0.0.0.0 --port <PORT>
- Command:
- 12.4 Comparison: Indie (Unified) vs Enterprise (Split)
- Indie (Standard Replicated):
python main.py-> Unified process (API + Worker). Scale by replicating the Pod. - Enterprise (Isolated Scaling): Split into
--mode=apiand--mode=workertargets.- Why?: Resource Partitioning (e.g., Workers need 4GB RAM for reports, APIs need 512MB for high-concurrency).
- NATS Synergy: Both modes use the same Queue Group name, ensuring messages are load-balanced correctly regardless of pod topology.
- Indie (Standard Replicated):
Part 13: UI Decomposition (The Remote MFE)
Section titled “Part 13: UI Decomposition (The Remote MFE)”Goal: Serve and register the Micro-Frontend (MFE) from the standalone service.
- 13.1 Build the MFE Remote
- Run the MFE build for the module:
cd libs/wms/frontend && pnpm build:mfe. - Verify
remoteEntry.jsis generated insrc/wms/static/mfe/.
- Run the MFE build for the module:
- 13.2 Host the MFE Assets
- Ensure the standalone service (
apps/wms-service) is configured to serve static files from the library’sstatic/directory. - Or upload to a CDN/S3 bucket for “Level 5” deployments.
- Ensure the standalone service (
- 13.3 Register with the Shell Host
- Access the main Shell Host’s configuration (e.g., K8s ConfigMap or
.env). - Add the service to
FRAMEWORK_M_MFE_REMOTES:{ "wms": "http://wms-service:8000" }
- Access the main Shell Host’s configuration (e.g., K8s ConfigMap or
- 13.4 Enable Proxy Fallback (Optional)
- If the browser cannot reach
wms-service:8000directly, enable the Shell Proxy on the main host:FRAMEWORK_M_MFE_PROXY_ENABLED=true
- If the browser cannot reach
- 13.5 Verify UI Discovery
- Open
http://shell-host:8000/api/v1/frontend/remotes. - Assert it contains:
"wms": "http://wms-service:8000/mfe/wms/1.2.3/remoteEntry.js".
- Open
- 13.6 Smoke Test the Desk
- Log in to the Desk UI.
- Navigate to a WMS-specific route (e.g.,
/wms/inventory). - Verify the remote MFE loads and communicates with the decomposed backend over NATS.
Part 14: Data Sovereignty & Distributed Reads
Section titled “Part 14: Data Sovereignty & Distributed Reads”Goal: Transition from SQL Foreign Keys (Monolith) to distributed logical references.
14.1 Logical References
Section titled “14.1 Logical References”Instead of hardcoding database-level Foreign Keys, services use Plain String IDs to reference data across domains.
class StockEntry(BaseDocType): supplier_id: str # Stored as plain UUID/string14.2 Resolution Patterns
Section titled “14.2 Resolution Patterns”The Application Adapter (see Part 3) is responsible for resolving these IDs based on the deployment mode:
-
Pattern A: Request/Response (RPC):
- Usecase: When we need the absolute latest, consistent state from the source.
- Implementation: The adapter dispatches a NATS
bus.request("rpc.Supplier.get", {"id": ...}). - Showcase:
FinanceresolvingSupplierterms for a payment.
-
Pattern B: CQRS Projections (Local Read-Models):
- Usecase: When we need high-throughput autonomy and survival.
- Implementation: The service maintains a local
projection_supplierstable, updated by listening toSupplier.updatedevents. - Showcase:
WMSresolvingItemnames during high-speed stock intake.
Part 15: Resolved Case Study - StockEntry -> Supplier
Section titled “Part 15: Resolved Case Study - StockEntry -> Supplier”15.1 The Application Showcase Path
Section titled “15.1 The Application Showcase Path”- Field Definition:
StockEntry.supplier_idis defined as a plain string. - Pattern A (RPC) Verification:
- Verified the
NatsSupplierAdaptercorrectly resolves the full partner object via NATS RPC.
- Verified the
- Pattern B (CQRS) Verification:
- Implemented a background NATS subscriber (
subscribe_supplier_events) inwms.services.projection_workerthat listens todoc.create,doc.update, anddoc.deleteSupplier events and enqueuesupdate_projection_supplierjobs. - Enriched
ProjectionSupplierread-model withsupplier_name,is_active, andlast_synced_atfields for full offline autonomy. StockEntryController.before_savenow validates both existence and active status against the local projection.- Verified (
tests/test_part15_cqrs_verification.py): GRN entry completes using local projection even if Business M is offline; 7 TDD scenarios cover enriched upsert, idempotency, offline GRN success, unknown-supplier rejection, inactive-supplier guard, delete-event handling, and timestamp freshness.
- Implemented a background NATS subscriber (
15.2 The Pattern Selection Matrix
Section titled “15.2 The Pattern Selection Matrix”| Scenario | Recommended Pattern | Reason |
|---|---|---|
| Domain Masters (Supplier) | NATS RPC | Consistency over autonomy. |
| Transactional Masters (Item) | CQRS Projection | High throughput and local survival. |
| External Engines (Ledger) | Specialized Protocol | External microservices are independent out-of-band handlers. |
Part 16: External Microservice Integration
Section titled “Part 16: External Microservice Integration”Goal: Bridge Framework M Macroservices to high-performance engines.
- 16.1 Book-Keeper Integration (TigerBeetle)
- Use the
ExternalLedgerProtocolto ship GL batches to the Litestar service.
- Use the
- 16.2 Outbox Strategy
- All external calls must be wrapped in an Outbox/Relay pattern to ensure transactional integrity in the Macroservice.