For me, modularity is more of a philosophical idea than just a technical term. It grows out of experience with source code architecture, but at the same time it is very closely related to simplicity and the long-term maintainability of larger projects.
Over the years in the open-source community, I have tried many programming languages and approaches, precisely in an effort to understand what “architecturally sustainable code” actually means. A major turning point for me came while working with JavaScript, when I did not use ready-made frameworks for single-page applications and instead built everything from scratch.
Suddenly, I was forced to think about an application as a set of separate parts that must not be tightly coupled. I was creating my own DOM elements that were meant to …
For me, modularity is more of a philosophical idea than just a technical term. It grows out of experience with source code architecture, but at the same time it is very closely related to simplicity and the long-term maintainability of larger projects.
Over the years in the open-source community, I have tried many programming languages and approaches, precisely in an effort to understand what “architecturally sustainable code” actually means. A major turning point for me came while working with JavaScript, when I did not use ready-made frameworks for single-page applications and instead built everything from scratch.
Suddenly, I was forced to think about an application as a set of separate parts that must not be tightly coupled. I was creating my own DOM elements that were meant to be independent of their surroundings and made no assumptions about the context in which they would be used. A natural consequence was the use of events as the primary channel for transferring information between these parts.
In this respect, modularity partly reminds me of metaprogramming. We call a function whose name is represented, for example, by a string, and we are not concerned with the concrete implementation—the important thing is that the operation exists and behaves as expected. I adopted this way of thinking mainly in the Ruby language, where metaprogramming is a natural part of the language and significantly influences application design style.
However, these principles are not limited to dynamic languages. They can be very well transferred to statically typed languages as well, such as V. V stands out primarily for its readability and simplicity, which allows the developer to focus mainly on the architecture of the application and a clear definition of the responsibilities of individual modules. Even when using an object-oriented approach, the code remains clean and easy to understand.
The core philosophy of modularity is that one module must not know anything about the internal implementation of another module. Yet it must be able to communicate with it effectively, and it must be possible to replace it with another implementation at any time. The key rule, therefore, is the absence of direct dependencies.
A good analogy is LEGO building blocks. Each piece is unique and has a clearly defined interface through which it can connect to other pieces. If we want to replace one piece with another, we simply detach it and attach a new one—without having to rebuild the entire structure. That is exactly the philosophy of modularity in software architecture.
1. State and Global Variables in V
In the V language, it is not possible to use global variables in the way we are used to from dynamic languages (Ruby, JavaScript). The author of the language aims to ensure code purity, consistency, and readability through this design choice.
If we tried to work around global state—for example by using program startup parameters—we would violate the philosophy of the language. Therefore, the recommended approach is:
- Each module (including the main one) maintains its own state structure, which is unique to the module but still importable into other parts of the application.
- The main module (main) serves as the entry point of the program, where a reference to the main structure (e.g. App) and its event bus is created.
- This reference is then passed to other modules, so that each module can work with events without creating copies of the state.
In this way:
- no global variables are created,
- the state lives for the duration of main() and its lifecycle is fully under the developer’s control,
- modularity and readability are preserved.
1.1 Main Module (main) as the Composition Root
The main module (main) serves as the starting point of the entire application. It is the only place where concrete instances of individual modules are created and where their dependencies are connected.
In the sample project, this is where the main application state (App) and its event bus are initialized. This state is not global—its lifecycle is tied to the execution of the main() function and is controlled exclusively from this location.
// main.v
fn main() {
mut ref_app := &src.App{
event_bus: events.new_event_bus()
}
mut ref_cli := cli.new(ref_app)
defer {
ref_cli.disconnect()
}
}
The App structure here owns the event bus and represents the central context of the application. Other modules only receive a reference to it and interact with it exclusively through the defined interface.
In this case, the CLI module does not know the concrete implementation of the application. It only cares that it receives an object that satisfies the required interface. This keeps the CLI module independent, easily testable, and replaceable.
An important detail is also the management of the module’s lifecycle:
- connection to the event bus occurs when the module is created,
- disconnection of handlers is explicitly managed using defer,
- no connections or disconnections happen implicitly.
Thus, the main module contains no application logic. Its sole responsibility is assembling the application—creating instances, passing references, and starting the program’s execution. This approach follows the composition root principle and allows individual modules to remain simple, clear, and loosely coupled.
1.2 Hexagonal Architecture in Practice
In practice, when creating a module like CLI:
- The module needs access to the event bus of the main structure, but should not know anything about the specific type of its parent.
- Therefore, we use an interface that defines only what the module requires (e.g., event_bus).
// src/app.v
pub interface IParent {
mut:
event_bus &events.EventBus
}
The CLI module therefore does not work with the concrete App structure, but only with this interface. As a result, it knows nothing about the parent’s implementation details.
This brings several advantages:
- Decoupling – The CLI is independent of the App. It does not need to know anything about the parent’s implementation.
- Composition flexibility – The order and method of assembling modules is not determined by their concrete implementation, but only by the fact that they satisfy the required interface.
- Modularity and testability – We can replace the parent with a mock, another module, or extend the system without affecting the existing logic.
Graphical Representation
[Main / App] ↔ [CLI]
↕ event bus
Each module has its own main structure, but they all share the same reference to the event bus, avoiding global variables and preserving the state for the duration of the application’s runtime.
Modules are therefore not directly connected to each other, but only through clearly defined boundaries, which is a fundamental principle of hexagonal architecture.
2. Event Bus
To keep modules truly independent, an interface alone is not enough—they also need a way to communicate without requiring direct knowledge of each other. This is where the event bus comes into play.
I use events very often, especially in situations where I want to maintain application modularity and minimize direct dependencies between its parts.
The principle of the event bus is simple: one part of the system emits an event, and another part can react to it without the two parts knowing each other.
This approach is common, for example, in JavaScript, where a programmer subscribes to an event, and when it is triggered, the registered handler (function) runs. The key point is that the source of the event does not know who is listening.
Thanks to this, we can write code so that:
- a module simply announces that something has happened,
- and other modules decide for themselves whether they are interested.
Code that reacts to events can be placed directly in the structure or class relevant to that behavior. This keeps the logic clearly separated, and each module can focus only on its own responsibility.
From an architectural perspective, the event bus can be seen as a communication layer between modules. Each module has a defined interface through which it can communicate via the event bus, without needing to know the concrete implementations of other modules.
[ modul A ] → (event bus) → [ modul B ]
Module A simply emits an event. Module B subscribes to it and reacts.
This exact principle is used in the sample project below, where the main application structure (App) only emits events, and the CLI module subscribes to them using handlers. This creates a loosely coupled architecture that is easy to extend, test, and maintain.
App Emits an Event
ref_app.event_bus.emit('app', 'test', 'ahoj')
The App module simply announces that something has happened. It does not care who reacts to the event.
CLI Reacts to the Event
self.parent.event_bus.connect('app', 'test', handler_test)
The CLI module subscribes to the event and reacts to it without needing to know the source of the event.
3. Interface
I have noticed that many programmers do not use interfaces correctly. They either implement them everywhere “just in case,” or they do not use them at all. In reality, the principle is very simple.
An interface is a contract between modules.
// src/cli/main.v
pub struct CLI {
mut:
parent src.IParent
}
The CLI module is therefore not dependent on the specific type of its parent. The only thing it cares about is that the given interface is satisfied.
In statically typed languages, functions typically work with specific types—structures or classes. This means that if a function accepts a specific type, it is tightly bound to it and cannot work with other types, even if only part of their behavior would suffice.
In dynamic languages (e.g., JavaScript), we often do not notice this problem because objects are dynamic, and the decision about what an object “can do” is made at runtime.
Static languages don’t allow this approach—and that’s by design. Instead, they use interfaces.
An interface allows us to say:
“I don’t care which specific type I get. I only care what operations it guarantees to provide.”
This creates a contract that a type must fulfill to be usable. A function or module then depends not on a concrete implementation, but only on this contract.
Thanks to this:
- modules can be separated from each other
- dependencies are reduced
- implementations can be swapped without changing the consumer
- and the benefits of static typing are preserved
An interface is therefore not a replacement for dynamism, but a controlled form of polymorphism that defines clear boundaries between parts of the system.
4. Complete Project Example
The following example summarizes all the principles described above into a single minimal project. Individual parts are not commented in detail—their purpose has been explained in the previous chapters.
4.1 Project Structure
.
├── main.v
├── src
│ ├── app.v
│ └── cli
│ ├── events.v
│ └── main.v
└── v.mod
4.2 Main State and Interface
src/app.v
module src
import fv.events
pub struct App {
pub mut:
event_bus &events.EventBus
}
pub interface IParent {
mut:
event_bus &events.EventBus
}
4.3 CLI Module
src/cli/main.v
module cli
import src
pub struct CLI {
mut:
parent src.IParent
}
pub fn new(parent src.IParent) &CLI {
mut self := &CLI{
parent: parent
}
self.connect()
return self
}
src/cli/events.v
module cli
import fv.events
fn handler_test(data []events.EventData) {
if data.len == 0 {
return
}
if data[0] is string {
println(data[0] as string)
}
}
fn (mut self CLI) connect() {
self.parent.event_bus.connect("app", "test", handler_test)
}
pub fn (mut self CLI) disconnect() {
self.parent.event_bus.disconnect("app", "test", handler_test)
}
4.4 Composition root (main)
main.v
module main
import fv.events
import src
import src.cli
fn main() {
mut ref_app := &src.App{
event_bus: events.new_event_bus()
}
mut ref_cli := cli.new(ref_app)
defer {
ref_cli.disconnect()
}
ref_app.event_bus.emit('app', 'test', "ahoj")
}