RGB-LDK-Node v0.0.8: Module Design After the Large-Scale Refactor
We recently performed a large-scale code refactor of RGB-LDK-Node — more than a hundred thousand lines of changes. This article explains what we did.
Refactoring overview
Before diving into the details, here is the shape of the new architecture after the refactor — a thin, composition-only root crate over a set of bounded-context crates:
The full before → after migration view is in the appendix.
Background
RGB-LDK-Node is the LDK Node fork maintained by Bitlight, adding RGB business support on top of upstream (opens in a new tab). Over time it has gradually accumulated a large number of features:
- Assets and contracts: listing/importing/issuing/exporting RGB contracts, consignments, balances, issuers
- Wallet: on-chain BTC wallet + RGB wallet, addresses/descriptors/sync, UTXO orchestration (reserve/release/fund/top_up/sweep), unified balances, etc.
- Payments: on-chain BTC, BOLT11 (including hold invoices), BOLT12, keysend, async payments, unified QR, RGB on-chain payments, RGB Lightning payments, payment record management, etc.
- Channels: open/close/force-close, splice, 0-conf, anchors, RGB asset funding (multiple UTXOs + policy overrides), observable close settlement, etc.
- Swaps: atomic BTC↔RGB exchange (Maker/Taker, multi-hop, state polling), etc.
- Liquidity: LSPS1/2, path scoring, etc. (The LSP-related design will be covered in a follow-up article.)
- Network and topology: peer management, gossip/RGS, network graph, node announcements, etc.
- Node runtime: lifecycle, event system, health checks, metrics and logging, etc.
- Security and keys: keystore, mnemonic envelope, sign/verify, key derivation, etc.
- Data protection and continuity: backup/restore, phased disaster recovery flows, etc.
- Access and delivery: HTTP API, daemon/CLI, UniFFI bindings, Docker, testing interoperability, etc.
As development went on, these features were either constantly being revised or constantly being piled on top of one another. The more things stacked up, the more design problems emerged.
The problem
For example, the Node gradually grew into a giant object with a huge number of fields, supporting almost every business feature. Every HTTP handler shares a single injected Arc<Node>. In terms of responsibility, the Node effectively became the sole composition root and a global service locator — subsystems could not exist independently, nor be tested independently. Along with this came the complexity of the src directory, where functionality of all different levels was piled into one enormous package. Although dividing by mod largely kept basic iteration workable, the unclear boundaries imposed a heavy mental-maintenance burden on developers.
In addition, the LDK / BDK / RGB wallets are essentially synchronous APIs. But because we had to cope with async operations, background tasks, and so on, the whole thing was wrapped in tokio, so every data point had to find its own way to bridge to the synchronous state. This meant we needed a clean facility to manage this adaptation uniformly — the engine crate mentioned later solves exactly this problem, and no place needs to manually handle the cross-cutting logic between sync and async, or between async and async, anymore.
Refactoring criteria
To make sure we were refactoring in the right direction, we set five criteria.
- Bounded context: by ownership, each capability lives in exactly one place. In other words, any business rule has exactly one authoritative definition in the whole repository. The model and its implementation stay in the same place, ensuring the model is not duplicated and avoiding the omission bugs that multiple sources of truth easily cause.
- Dependency rules: you may only depend on things more stable and more abstract than yourself; the root crate depends on each capability so it can do the wiring, while the capability layer depends only on interfaces and shared infrastructure.
- Ports and adapters: cross-capability collaboration goes only through the defined interfaces/ports; implementation details stay on each side.
- Deep modules: each module keeps its external interface as simple and stable as possible, with the complexity hidden inside the module.
- Checkable conventions: boundaries are guarded by architecture, scripts, and tests, so that accidentally introduced code cannot corrupt the new architecture.
The division between and within layers
We first split the monolith — where almost all the code lived in the src directory — into three layers.
As shown above: external entry points (T) can only see the interfaces (A) and make calls through them — business matters are handled by the
node-*packages (N), runtime concerns (startup/shutdown, sync, scheduling) by the engine (E), and the concrete wiring is decided in one place by the composition root (R).So calls always go top-down, and dependencies always go bottom-up — in the whole system, only the composition root has seen both the interfaces and the implementations.
1) The supporting layer
We divided it into the following crates:
domain holds the domain invariants that must be owned in exactly one place — identifiers, values, events, the payment lifecycle, and so on — which are independent of the runtime, including state machines, transition matrices, aggregate wrappers, and more. Any management policy (concrete implementation) related to these concepts is forbidden from living in this crate.
application is made of traits and small ports split by capability; the executor is chosen by the caller.
It has four constituent parts:
- Use-case commands (
commands.rs): the cross-context command vocabulary. Event adapters and the HTTP layer construct intents, and services execute them against registries + ports. - Query views and results (
*View/Prepared*): read models and prepared artifacts. We deliberately make it explicit that a View is not a transport DTO, which means a View can carry derived logic, and one View can project multiple or many kinds of DTOs. This way, even if the DTOs change frequently, the View itself stays relatively stable. - Ports (
*ports): divided into two kinds according to whether the application is called by the outside or calls the outside.- One kind is the driving ports: 15+ capability facades, defined in
applicationand implemented by the business crates.- They can be invoked through the Node via HTTP/UniFFI. These are the narrow interfaces the Node exposes to HTTP/UniFFI.
- In the pre-refactor version, all of this code was stuffed into the Node struct, which was extremely bloated.
- The other kind is the driven ports: 20+ Ports / Repositories, defined in
applicationand implemented on the infrastructure side. - This division serves to separate the axes of change (Ousterhout): Driving evolves with the request surface, Driven evolves with the environment, thereby ensuring dependencies always point at the domain core, and the request surface and the environment can each change and be tested independently without touching the core — achieving extremely strong flexibility.
- One kind is the driving ports: 15+ capability facades, defined in
- A unified vocabulary:
ApplicationFuture(executor-agnostic async returns),ApplicationError,ApplicationResult, and so on.
And as the foundation for these business capabilities, engine is the adaptation layer of the node runtime. It manages the start and stop across the period from boot to shutdown, as well as the background tasks once the node is running — for example, when to sync, how to retry, and how sessions are started and stopped.
2) The capability layer
Following the principle of one crate per bounded context, we split the business out of rgb-ldk-node, moving each business BC into its own node-* package:
| Layer | Crate | Content |
|---|---|---|
| Infrastructure | node-io | Persistence SPI: KV ports, SqliteStore, artifact, seed, BDK changeset serialization |
| node-chain | Chain backends: Esplora / Electrum / bitcoind, fee estimation, transaction broadcast | |
| Capability | node-payment | Lightning + RGB payments (bolt11/12, onchain, RGB payment) |
| node-swap | Swap lifecycle, offer, registry | |
| node-liquidity | LSPS1/2 liquidity | |
| node-ln | channel / p2p / event / SDK sync | |
| node-wallet | Wallet + UTXO orchestrator | |
| node-ops | backup / disaster / security (keystore, RGB archive codec) | |
| Transport | node-http | HTTP control plane: handlers / DTO / OpenAPI / MainApiServer |
We agreed that capability crates are forbidden from referencing each other's implementations and may only depend on the infrastructure (node-io / node-chain); when cross-capability collaboration is truly needed, it must go through application ports.
Although in practice this can neither eradicate coupling nor eliminate business complexity — because the business itself is extremely complex — it does centralize and make the coupling explicit, lowering the decision cost of every subsequent change and improving overall maintainability. On the whole, we got a good return.
3) The root crate
Another benefit of this design is that it made the root crate thoroughly clear and simple (previously, expanding the src directory would show dozens of folders and dozens of huge business-implementation files from different domains). The root crate now keeps only the public API and composition. The dependency graph also became a DAG, ruling out unreasonable dependency orders, and many tests no longer need to spin up the whole system to run.
SPI
Service Provider Interface is a concept borrowed from the Java ecosystem (the JDBC / SLF4J approach). Its meaning: the library only needs to define the contract and let the host application provide the implementation. Although the Java ecosystem is often criticized for over-engineering, what this embodies is the Interface Segregation Principle — we borrowed this concept to wrap the io, security, and ops operations: the root package only acts as a forwarding table, and the implementations all live in the capability crates.
Before the refactor, features like backup / disaster / io / keystore each had their own entry points, and integrators had to decide on their own how to integrate with each one. After reorganizing them into spi::*: non-replaceable, ordinary functionality goes through the root handle, while anything extensible/replaceable goes through spi::* — balancing practicality and flexibility.
Limitations
In this refactor, we focused mainly on using framework-level constraints to make the structure clear, placing capabilities in the right positions so that unreasonable dependencies cannot easily be constructed. However, the concrete details inside each module still have many problems and still require further refactoring later. Meanwhile, the root crate's composition is still fairly heavy — this soft debt is concentrated in the facade and assembly code (mainly the node builder).