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/)

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/)

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/)

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/)

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, not TaskID)

  • 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, not version)

  • 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, not TaskID)

  • 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

Level
Scope
Type
Fields
Serializable
Import

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:

  1. Does another application need this message?

    • No → Keep type unexported (Level 1 or 2)

    • Yes → Export type (Level 3 or 4)

  2. 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 any or interface{} for messages

  • Include pointers in network messages

Dependencies

Do:

  • Applications import types/ for shared contracts

  • Applications import lib/ for utilities

  • Entry 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.Getenv directly in actors

  • Store configuration in global variables

Last updated