Module Context Model (deps / backends / hooks / ops / callbacks)
Purpose
This document describes the context-based architecture adopted in the project to structure dependencies, interchangeable implementations, and extension points in a modular, testable, and explicit C codebase.
This model is designed to:
- apply SOLID principles in practice (especially DIP and ISP),
- avoid hidden dependencies and service locators,
- allow controlled dependency injection (tests, variants),
- keep the public API minimal and readable,
- support selective test doubling without over-engineering.
A key idea of this document is that everything can be made doublable, but nothing is required to be.
Each module should expose only the seams that are architecturally meaningful.
Overview
Each module may define an optional context structure (<module>_ctx_t) used only at creation time to configure the module instance.
The context aggregates potential injection points:
- deps : dependencies on other modules or services,
- backends : interchangeable implementations (ports / adapters),
- hooks : inbound extension points required for the module to operate,
- ops : optional internal strategies or policies (if substitution is desired),
- callbacks : outward notifications,
- user_data : opaque caller-owned state for injected functions.
Typical maximal shape (all fields are optional):
typedef struct <module>_ctx_t {
<module>_deps_t deps;
const <module>_backends_t *backends;
const <module>_hooks_t *hooks;
const <module>_ops_t *ops;
<module>_callbacks_t callbacks;
void *user_data;
} <module>_ctx_t;
IMPORTANT
This structure represents the upper bound of what can be injected.
A real module is expected to keep only the fields it actually needs.
The ctx is optional: if NULL, the module falls back to its default configuration.
1. Dependencies (deps)
Role
deps represents explicit dependencies on other internal project modules or services required by the module at runtime.
A dependency may be expressed as:
- a pointer to another module’s handle (
dep_t *), or
- a pointer to an interface (ops + optional user data).
OSAL rule
All interactions with the outside world must go through OSAL.
OSAL is treated as a regular internal module, but it is the only layer allowed to access libc or OS-level facilities.
As a consequence, project modules:
- never call
malloc, free, or other libc functions directly,
- never access OS services implicitly,
- rely exclusively on OSAL public APIs or OSAL ops tables.
This keeps external effects visible, controlled, and auditable.
Example: injectable OSAL memory dependency
#include "osal_mem.h"
typedef struct module_deps_t {
} module_deps_t;
void *module_create_object(module_t *m, size_t size)
{
return m->deps.mem->malloc(size);
}
Definition osal_mem_ops.h:12
Design rules
deps expresses runtime needs, not mere include relationships.
- Dependencies are injected explicitly (no lookups, no globals).
deps may be:
- copied into the module handle, or
- referenced via pointer (lifetime managed by the bootstrap).
2. Backends
Role
Backends represent interchangeable implementations of a given concept, typically following a port / adapter pattern.
Common examples:
- stdio / fs / dynamic_buffer streams,
- flex / fake_lexer,
- real_backend / trace_backend.
They are expressed via immutable vtables (*_ops_t).
Adopted model
typedef struct <module>_backends_t {
const <module>_<backend_1>_ops_t *backend_1;
const <module>_<backend_2>_ops_t *backend_2;
} <module>_backends_t;
And in the context:
const <module>_backends_t *backends;
Rationale
- Vtables are immutable → const pointers.
- No copying of vtables.
- Explicit selection of implementations.
- Clean substitution in tests.
Guideline
Backends are primarily intended for ports/adapters.
Internal core modules usually depend on handles, not backends.
3. Hooks (inbound extension points)
Role
Hooks represent inbound extension points: functions provided by the host application and called by the module to obtain data or services it requires to operate.
Hooks are not notifications.
They are functional dependencies, expressed as function pointers.
Typical example:
read_ast (interpreter requests the next AST),
Example
typedef struct <module>_hooks_t {
struct ast *(*read_ast)(
void *user_data);
} <module>_hooks_t;
Design rules
- Hooks are required or optional depending on the module semantics.
- Hooks usually rely on
user_data to access host-owned resources.
Terminology note
In professional C architectures, such functions are commonly called hooks or ports, not callbacks.
4. Ops (internal strategies – optional)
Role
ops represents internal policies or strategies of a module that may be substituted for testing or variation purposes.
Examples:
- alternative error handling strategies,
- internal algorithms,
- tracing or instrumentation hooks,
- deliberately replaceable internal behavior.
Important principle
Making a module “fully doublable” does not mean making every function virtual.
Only operations that represent meaningful architectural seams should be placed in ops.
Everything else should remain direct, concrete code.
Example
typedef struct module_ops_t {
int (*process)(module_t *m, int value);
} module_ops_t;
Rule of thumb
- Use
ops sparingly and intentionally.
- Prefer doubling dependencies and effects over doubling the module’s own logic.
- If all public functions are in
ops, the module is effectively a port.
5. Callbacks
Role
Callbacks allow the module to emit notifications to the outside world (errors, diagnostics, events) without tight coupling.
Callbacks are outbound only.
Example
typedef struct <module>_callbacks_t {
void (*on_error)(void *user_data, const char *msg);
} <module>_callbacks_t;
Rules
- All callbacks are optional (NULL allowed).
- Always paired with
user_data.
- No callback must be required for correct module behavior.
6. Context (ctx)
Role
The context is a configuration and injection input, not runtime state.
It is consumed by the module constructor (create, init, etc.) to initialize the module instance.
Key properties
- May be NULL.
- May be partially filled.
- Must not be modified by the module.
- May reference static const data.
- Has no lifetime beyond module creation.
Crucial rule
The module does not store the ctx as-is.
It stores only the fields it actually needs in its handle.
7. Defaults
Each module may provide default factories:
<module>_deps_t module_default_deps(void);
const <module>_backends_t *module_default_backends(void);
const <module>_hooks_t *module_default_hooks(void);
const <module>_ops_t *module_default_ops(void);
Optionally:
const <module>_ctx_t *module_default_ctx(void);
Rules
- Default objects are static const.
- No mutable global state.
- Thread-safe by construction.
- Defaults are applied during creation, not looked up dynamically.
8. What this model avoids
- Hidden dependencies.
- Service locators / singletons.
#ifdef TEST.
- Mutable global variables.
- Over-mocking or over-virtualization.
9. What this model enables
- Explicit dependency injection.
- Fine-grained and selective test doubling.
- Clear architectural seams.
- Black-box testing where appropriate.
- White-box testing where valuable.
- Readable, maintainable, evolvable C code.
10. When to use this model
Recommended if the module:
- depends on external services,
- has multiple implementations or strategies,
- requires controlled test isolation.
Not required if the module:
- is trivial or purely functional,
- has no external effects,
- has a single stable implementation.
Note on stateless / purely functional modules
Some modules in the codebase are pure or stateless by nature.
For such modules:
- No handle is created.
- No constructor or destructor is required.
- No runtime state or lifetime management exists.
Their public API typically consists of plain functions:
result_t module_do_something(arg1, arg2);
or, when a configuration or policy is required:
result_t module_do_something(const module_cfg_t *cfg, arg1, arg2);
In this case:
module_cfg_t represents a configuration, not a dependency container.
- It is usually small and often
static const.
- It may be injected into consumers when appropriate.
Important distinction
For stateless modules, a cfg is not a dependency injection mechanism in the same sense as a ctx:
- it does not manage lifetimes,
- it does not carry resources or services,
- it does not imply ownership.
Guideline
Do not introduce a ctx or handle for a module unless it actually needs:
- runtime state,
- external dependencies,
- interchangeable implementations,
- or controlled test isolation.
In short:
- Stateful modules → handle + optional ctx at creation time.
- Stateless modules → plain functions, optional config passed explicitly.
- Avoid uniformity for its own sake: architectural clarity is preferred over mechanical consistency.
Conclusion
The ctx / deps / backends / hooks / ops / callbacks model defines a maximal architectural envelope.
It allows everything to be made doublable, but encourages intentional restraint:
Make explicit only what is architecturally meaningful to substitute.
This balances rigor and pragmatism, and serves as a reference standard for the project’s core modules.