Project Structure
How to Structure Projects Built with Ergo Framework
The same codebase can run as a monolith on your laptop or as distributed services across a data center. This flexibility comes from one principle: applications are the unit of composition. How you organize your project determines whether you can use this flexibility or fight against it.
This chapter covers project organization, message isolation patterns, deployment strategies, and evolution paths. The goal is a structure that supports both development simplicity and production scalability without code changes.
The Flexibility Promise
Ergo's network transparency means a process doesn't know if it's talking to a neighbor in the same node or a remote process across the network. The same Send() call works either way. But this only helps if your code is organized to take advantage of it.
Consider two deployment scenarios:
Development: All applications in one process for fast iteration.
Production: Applications distributed across nodes for scalability.
The application code is identical in both cases. Only the entry point changes - which applications start on which nodes.
This works because:
Applications are self-contained functional units
Messages define contracts between applications
The framework handles routing transparently
Your project structure must preserve these properties. Mix them up, and you lose deployment flexibility.
Directory Layout
A well-structured project separates entry points from applications from shared code:
Entry Points (cmd/)
cmd/)Each directory in cmd/ produces a different binary with a different deployment topology.
Monolith - everything together:
Distributed - each application on its own node:
The application code (apps/api, apps/worker) is identical. The entry point decides what runs where.
Applications (apps/)
apps/)Each subdirectory in apps/ is a self-contained application. An application is:
A cohesive functional unit
Deployable independently
Composed of actors with a supervision tree
Communicating via messages
Application structure:
Application definition:
Applications should not import each other. If apps/api imports apps/worker, you've created a compile-time dependency that limits deployment flexibility.
Service-Level Types (types/)
types/)When applications need to communicate, they need shared message types. The types/ directory holds these contracts:
Both apps/orders and apps/shipping can import types without importing each other. This breaks the circular dependency while maintaining strong typing.
Shared Libraries (lib/)
lib/)Non-actor code that multiple applications use goes in lib/:
Libraries must be:
Stateless - no global variables, no goroutines
Pure - same inputs produce same outputs
Actor-agnostic - no dependency on
gen.Process
Libraries are safe to call from actor callbacks because they don't block or manage state.
Message Isolation Levels
Messages define contracts between actors. The visibility of message types controls who can send them and where they can travel. Ergo uses Go's export rules plus EDF serialization requirements to create four isolation levels.
Understanding these levels is critical for proper encapsulation.
Level 1: Application-Internal (Same Node)
Messages used only within a single application instance on one node.
Characteristics:
Type is unexported (
scheduleTask)Fields are unexported (
taskID, notTaskID)Cannot be imported by other packages
Cannot be serialized for network transmission
Maximum encapsulation
Use when:
Communication between actors in the same application
Messages never leave the local node
Implementation details that shouldn't be exposed
Level 2: Application-Cluster (Same Application, Multiple Nodes)
Messages between instances of the same application across nodes.
Characteristics:
Type is unexported (
replicateState)Fields are exported (
Version, notversion)Cannot be imported by other packages
CAN be serialized (EDF requires exported fields)
Internal to application, but network-capable
Use when:
Replication between application instances
Cluster-internal coordination
Messages that other applications shouldn't see
Level 3: Cross-Application (Same Node Only)
Messages between different applications on the same node.
Characteristics:
Type is exported (
StatusQuery)Fields are unexported (
taskID, notTaskID)CAN be imported by other packages
Cannot be serialized (unexported fields block EDF)
Cross-application but local-only
Use when:
Local service queries
Same-node optimization paths
Explicitly preventing network transmission
This level is intentionally restrictive. If someone tries to send StatusQuery to a remote node, serialization fails. The unexported fields act as a compile-time guard against accidental network use.
Level 4: Service-Level (Everywhere)
Messages that form public contracts between applications across the cluster.
Characteristics:
Type is exported (
ProcessTask)Fields are exported (
TaskID)CAN be imported by any package
CAN be serialized
Full network transparency
Use when:
Public API between applications
Events that multiple applications subscribe to
Commands sent across application boundaries
Summary Table
1
Within app, same node
unexported
unexported
No
No
2
Same app, any node
unexported
Exported
Yes
No
3
Cross-app, same node
Exported
unexported
No
Yes
4
Everywhere
Exported
Exported
Yes
Yes
Choosing the Right Level
Start with Level 1 (maximum restriction). Only increase visibility when needed:
Does another application need this message?
No → Keep type unexported (Level 1 or 2)
Yes → Export type (Level 3 or 4)
Does this message cross node boundaries?
No → Keep fields unexported (Level 1 or 3)
Yes → Export fields (Level 2 or 4)
Application Design Patterns
Supervision Structure
Applications typically have a supervision tree:
Configuration via Options
Applications accept configuration through an Options struct:
Entry points configure options based on deployment:
Inter-Application Communication
Applications discover each other through application names, not node names:
When running as monolith, routes returns the local node. When distributed, it returns remote nodes. The code doesn't change.
Event Publishing
Applications publish events for loose coupling:
Events decouple applications. Orders doesn't know who listens. Shipping doesn't know where Orders runs.
Deployment Patterns
Pattern 1: Development Monolith
Everything in one process for fast iteration:
Benefits:
Single binary to run
No network setup
Easy debugging
Fast startup
Pattern 2: Distributed Production
Each application on dedicated nodes:
Each binary runs one application:
Benefits:
Independent scaling per tier
Fault isolation
Resource optimization
Zero-downtime updates
Pattern 3: Hybrid Deployment
Group related applications for efficiency:
Benefits:
Reduced network hops for common paths
Fewer nodes to manage
Right-sized for actual traffic patterns
Testing Strategies
Unit Testing Actors
Test actors in isolation using the testing framework:
Integration Testing Applications
Test complete applications:
Testing Distributed Scenarios
Test multiple nodes:
Evolution and Refactoring
Starting Simple
Begin with a monolith:
Extracting Applications
When the monolith grows, extract bounded contexts:
Step 1: Identify boundaries in the combined application.
Step 2: Create separate application packages.
Step 3: Update the entry point.
Step 4: When ready, create distributed entry points.
The application code never changes. Only entry points and deployment.
Merging Applications
If you over-distributed:
No application code changes. Just different composition.
Best Practices
Application Boundaries
Do:
One application per bounded context
Applications that scale together can be one application
Applications that deploy together can be one application
Don't:
Create applications for single actors
Split applications by technical layer (web/service/data)
Create circular dependencies between applications
Good:
Bad:
Message Design
Do:
Start with Level 1 (most restrictive)
Increase visibility only when needed
Document which level each message uses
Register Level 4 types with EDF
Don't:
Default to Level 4 for everything
Mix isolation levels arbitrarily
Use
anyorinterface{}for messagesInclude pointers in network messages
Dependencies
Do:
Applications import
types/for shared contractsApplications import
lib/for utilitiesEntry points import applications
Don't:
Applications import other applications
Libraries depend on applications
Create import cycles
Configuration
Do:
Use Options structs for application config
Validate in CreateApp or Load
Provide sensible defaults
Read environment in entry points
Don't:
Hard-code configuration in actors
Read
os.Getenvdirectly in actorsStore configuration in global variables
Last updated
