Dynamic Modules with dylo
snug uses a dynamic module loading system called dylo (short for “dynamic loading”) to enable a flexible architecture with cleanly separated components.
Overview of dylo
dylo is a Rust framework that allows modules to be loaded dynamically at runtime, providing:
- Clean separation between interface and implementation
- The ability to update components independently
- A plugin architecture that avoids dependency bloat
- Faster iteration during development
- Efficient deployment with layer caching
Crate Structure
The snug workspace contains two types of crates for each module:
Implementation Crates (mod-*
)
- Named with the
mod-
prefix (e.g.,mod-config
,mod-media
) - Contain the actual implementation code
- Compiled as dynamic libraries (
.so
files) - These are the only crates you should manually edit
Client Crates
- Named without the prefix (e.g.,
config
,media
) - Automatically generated from the corresponding
mod-*
crate - Provide the interface used by the main application
- Never edit these manually - they’re regenerated when implementations change
Development Workflow
When modifying or creating modules:
- Edit the implementation crate (e.g.,
mod-config
) - Run
dylo
to generate the client interface - Build and test your changes with
just run serve
Module Building Process
Both development and production builds pre-compile all modules and disable dylo’s automatic building with DYLO_BUILD=0
.
Development Build
The development build process is controlled by the run
command in the Justfile
. This process:
- Finds all workspace members with names containing
mod-
- Runs the
dylo
tool to generate client interfaces - Builds all modules together using Cargo
- Copies the compiled libraries to a runtime directory
- Sets
DYLO_BUILD=0
to use pre-built modules
This approach leverages Cargo’s workspace-aware build system, which efficiently:
- Deduplicates dependencies across crates
- Uses build pipelining to overlap compilation steps
- Shares compilation artifacts between modules
Production Build
The production build process is defined in the Earthfile
and:
- Identifies all
mod-*
crates usingcargo metadata
- Builds them together with the
impl
feature enabled - Compresses debug sections for production use
- Critically: Copies each module as a separate container layer
This multi-layer approach provides significant deployment advantages:
- When one module changes, only its layer needs to be rebuilt
- Unchanged modules are reused from the container cache
- Deployments complete much faster when most modules are unchanged
This optimizes the CI/CD pipeline by making incremental deployments extremely efficient.
Performance Trade-offs
The dynamic module system involves trade-offs:
- Build time: Faster rebuilds during development (only changed modules are relinked)
- Startup time: Slightly slower initial startup (runtime module loading)
- Deploy time: Much faster deployments (layer-based caching)
- Runtime overhead: Small performance cost due to dynamic dispatch
For snug, these trade-offs make sense because the benefits to development and deployment efficiency outweigh the minor runtime cost.
Technical Implementation
Module Discovery
dylo automatically processes any workspace member with the mod-
prefix. The client crate name is derived by removing this prefix:
mod-config
→config
mod-media
→media
Creating a new module is as simple as adding a new mod-
prefixed crate to the workspace.
The .dylo
Directory
When you run dylo
, it generates two key files in each client crate:
spec.rs
: Contains the interface derived from the implementationsupport.rs
: Includes code for dynamically loading the module
The #[dylo::export]
Attribute
The #[dylo::export]
attribute marks types and functions for exposure in the client interface:
// In mod-config/src/lib.rs
#[dylo::export]
impl Mod for ModImpl {
fn load_config(&self, site_path: &Utf8Path) -> Result<Config> {
// Implementation...
}
}
The attribute automatically extracts the public API from the implementation. You never manually define interfaces - they’re generated from the implementation.
The impl
Feature
The impl
feature flag separates implementation details from the interface:
// In mod-config/src/lib.rs
#[cfg(feature = "impl")]
#[derive(Default)]
struct ModImpl;
#[dylo::export]
impl Mod for ModImpl {
// Implementation methods
}
Code marked with #[cfg(feature = "impl")]
is:
- Included when building implementation crates
- Stripped when generating client interfaces
This applies to both source code and dependencies in the Cargo manifest:
# In mod-config/Cargo.toml
[dependencies]
# Core dependencies available to both impl and interface
dylo.workspace = true
camino = { version = "1.1.7" }
# Implementation-only dependencies marked as optional
fs-err = { version = "2.11.0", optional = true }
dotenvy = { version = "0.15.7", optional = true }
figment = { version = "0.10.19", optional = true }
[features]
default = ["impl"]
# The impl feature enables all implementation-only dependencies
impl = [
"dep:dotenvy",
"dep:figment",
"dep:fs-err"
]
This pattern ensures that implementation dependencies are only included when building the actual module, not when using the client interface.
Interface Requirements
All exported interfaces must be in the top-level module (usually lib.rs
), including:
- All
impl
blocks marked with#[dylo::export]
- All types used in the public interface
- All function signatures in the public API
Implementation details can be placed in submodules, but the interface must be defined at the root level.
Resources
- dylo GitHub repository: github.com/bearcove/dylo
- To install the dylo CLI:
cargo install dylo-cli