> For the complete documentation index, see [llms.txt](https://docs.ergo.services/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.ergo.services/basics/project-structure.md).

# 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.

```mermaid
graph TB
    subgraph "Single Node"
        API[API App]
        Worker[Worker App]
        Storage[Storage App]
        API --> Worker
        Worker --> Storage
    end
```

**Production**: Applications distributed across nodes for scalability.

```mermaid
graph TB
    subgraph "API Node"
        API[API App]
    end

    subgraph "Worker Node 1"
        W1[Worker App]
    end

    subgraph "Worker Node 2"
        W2[Worker App]
    end

    subgraph "Storage Node"
        Storage[Storage App]
    end

    API --> W1
    API --> W2
    W1 --> Storage
    W2 --> Storage
```

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:

```
project/
├── cmd/                        # Entry points
│   ├── monolith/main.go       # All apps together
│   ├── api/main.go            # API node
│   ├── worker/main.go         # Worker node
│   └── storage/main.go        # Storage node
│
├── apps/                       # Application packages
│   ├── api/
│   │   ├── app.go             # Application definition
│   │   ├── handler.go         # Request handling actor
│   │   ├── router.go          # Routing logic
│   │   ├── messages.go        # Internal messages
│   │   └── supervisor.go      # Supervision tree
│   ├── worker/
│   │   ├── app.go
│   │   ├── processor.go
│   │   ├── queue.go
│   │   ├── messages.go
│   │   └── types.go
│   └── storage/
│       ├── app.go
│       ├── reader.go
│       ├── writer.go
│       ├── messages.go
│       └── types.go
│
├── types/                      # Service-level contracts
│   ├── events.go              # Cross-application events
│   └── commands.go            # Cross-application commands
│
├── lib/                        # Shared non-actor code
│   ├── config/                # Configuration utilities
│   └── models/                # Domain models
│
└── go.mod
```

### Entry Points (`cmd/`)

Each directory in `cmd/` produces a different binary with a different deployment topology.

**Monolith** - everything together:

```go
// cmd/monolith/main.go
package main

import (
    "myproject/apps/api"
    "myproject/apps/worker"
    "myproject/apps/storage"

    "ergo.services/ergo"
    "ergo.services/ergo/gen"
)

func main() {
    node, _ := ergo.StartNode("app@localhost", gen.NodeOptions{
        Applications: []gen.ApplicationBehavior{
            api.CreateApp(api.Options{Port: 8080}),
            worker.CreateApp(worker.Options{Concurrency: 10}),
            storage.CreateApp(storage.Options{DSN: "postgres://..."}),
        },
    })
    node.Wait()
}
```

**Distributed** - each application on its own node:

```go
// cmd/api/main.go
package main

import (
    "myproject/apps/api"

    "ergo.services/ergo"
    "ergo.services/ergo/gen"
    "ergo.services/registrar/etcd"
)

func main() {
    node, _ := ergo.StartNode("api@api-server-1", gen.NodeOptions{
        Applications: []gen.ApplicationBehavior{
            api.CreateApp(api.Options{Port: 8080}),
        },
        Network: gen.NetworkOptions{
            Registrar: etcd.Create(etcd.Options{
                Endpoints: []string{"etcd:2379"},
                Cluster:   "production",
            }),
        },
    })
    node.Wait()
}
```

```go
// cmd/worker/main.go
package main

import (
    "myproject/apps/worker"

    "ergo.services/ergo"
    "ergo.services/ergo/gen"
    "ergo.services/registrar/etcd"
)

func main() {
    node, _ := ergo.StartNode("worker@worker-1", gen.NodeOptions{
        Applications: []gen.ApplicationBehavior{
            worker.CreateApp(worker.Options{Concurrency: 50}),
        },
        Network: gen.NetworkOptions{
            Registrar: etcd.Create(etcd.Options{
                Endpoints: []string{"etcd:2379"},
                Cluster:   "production",
            }),
        },
    })
    node.Wait()
}
```

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:**

```
apps/worker/
├── app.go              # Application behavior implementation
├── processor.go        # Main processing actor
├── queue.go            # Queue management actor
├── supervisor.go       # Supervision strategy
├── messages.go         # Message types (see isolation levels)
└── types.go            # Domain types used in messages
```

**Application definition:**

```go
// apps/worker/app.go
package worker

import "ergo.services/ergo/gen"

type Options struct {
    Concurrency int
    QueueSize   int
}

func CreateApp(opts Options) gen.ApplicationBehavior {
    return &app{options: opts}
}

type app struct {
    app.Application
    options Options
}

func (a *app) Load(args ...any) (gen.ApplicationSpec, error) {
    return gen.ApplicationSpec{
        Name:        "worker",
        Description: "Background task processing",
        Weight:      100,
        Group: []gen.ApplicationMemberSpec{
            {Name: "queue", Factory: a.createQueue},
            {Name: "supervisor", Factory: a.createSupervisor},
        },
        Env: map[gen.Env]any{
            "CONCURRENCY": a.options.Concurrency,
            "QUEUE_SIZE":  a.options.QueueSize,
        },
    }, nil
}
```

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:

```go
// types/events.go
package types

import (
    "time"

    "ergo.services/ergo/gen"
)

// Events published by the orders application
type OrderCreated struct {
    OrderID    string
    CustomerID string
    Total      int64
    CreatedAt  time.Time
}

type OrderCompleted struct {
    OrderID     string
    CompletedAt time.Time
}

// Helper that consumers call from their application's Load() callback.
func RegisterTypes(network gen.Network) error {
    return network.RegisterTypes([]any{
        OrderCreated{},
        OrderCompleted{},
    })
}
```

Both `apps/orders` and `apps/shipping` can import `types` and call `types.RegisterTypes(a.Node().Network())` from their `Load` callbacks. The callback signature is `Load(args ...any) (gen.ApplicationSpec, error)` - the node is not a parameter, it is reached through the embedded `app.Application` as `a.Node()`. This breaks the circular dependency while maintaining strong typing.

### Shared Libraries (`lib/`)

Non-actor code that multiple applications use goes in `lib/`:

```go
// lib/config/config.go
package config

import "os"

func DatabaseURL() string {
    return os.Getenv("DATABASE_URL")
}

// lib/models/order.go
package models

type Order struct {
    ID         string
    CustomerID string
    Items      []OrderItem
    Total      int64
}
```

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.

```go
// apps/worker/messages.go
package worker

// Unexported type, unexported fields
// Cannot be referenced outside this package
// Cannot be serialized (unexported fields)
type scheduleTask struct {
    taskID   string
    priority int
    data     []byte
}

type taskCompleted struct {
    taskID string
    result []byte
}
```

**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

```mermaid
graph LR
    subgraph "Worker Application (Node 1)"
        Q[Queue] -->|scheduleTask| P[Processor]
        P -->|taskCompleted| Q
    end
```

### Level 2: Application-Cluster (Same Application, Multiple Nodes)

Messages between instances of the same application across nodes.

```go
// apps/worker/messages.go
package worker

// Unexported type, EXPORTED fields
// Cannot be referenced outside this package
// CAN be serialized (exported fields)
type replicateState struct {
    Version   int64   // Exported for EDF
    TaskIDs   []string
    Positions map[string]int
}

type syncRequest struct {
    FromVersion int64
    ToVersion   int64
}
```

**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

```mermaid
graph LR
    subgraph "Node 1"
        W1[Worker Instance]
    end

    subgraph "Node 2"
        W2[Worker Instance]
    end

    W1 -->|replicateState| W2
    W2 -->|syncRequest| W1
```

### Level 3: Cross-Application (Same Node Only)

Messages between different applications on the same node.

```go
// apps/worker/messages.go
package worker

// EXPORTED type, unexported fields
// CAN be referenced by other packages
// Cannot be serialized (unexported fields)
type StatusQuery struct {
    taskID string  // unexported - prevents network use
}

type StatusResponse struct {
    taskID   string
    status   string
    progress int
}
```

**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

```mermaid
graph LR
    subgraph "Node 1"
        API[API App] -->|StatusQuery| Worker[Worker App]
        Worker -->|StatusResponse| API
    end
```

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.

```go
// types/commands.go
package types

// EXPORTED type, EXPORTED fields
// CAN be referenced by any package
// CAN be serialized
type ProcessTask struct {
    TaskID   string
    Priority int
    Payload  []byte
}

type TaskResult struct {
    TaskID string
    Status string
    Output []byte
    Error  string
}
```

Each consuming application registers the shared types from its `Load` callback:

```go
// apps/worker/app.go
package worker

import (
    "ergo.services/ergo/gen"

    "myapp/types"
)

func (a *Worker) Load(args ...any) (gen.ApplicationSpec, error) {
    err := a.Node().Network().RegisterTypes([]any{
        types.ProcessTask{},
        types.TaskResult{},
    })
    if err != nil {
        return gen.ApplicationSpec{}, err
    }
    return gen.ApplicationSpec{ /* ... */ }, nil
}
```

**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

```mermaid
graph LR
    subgraph "Node 1"
        API[API App]
    end

    subgraph "Node 2"
        W1[Worker App]
    end

    subgraph "Node 3"
        W2[Worker App]
    end

    API -->|ProcessTask| W1
    API -->|ProcessTask| W2
    W1 -->|TaskResult| API
    W2 -->|TaskResult| API
```

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

```mermaid
flowchart TD
    A[New Message Type] --> B{Used by other apps?}
    B -->|No| C{Crosses nodes?}
    B -->|Yes| D{Crosses nodes?}
    C -->|No| L1[Level 1<br/>unexported type<br/>unexported fields]
    C -->|Yes| L2[Level 2<br/>unexported type<br/>Exported fields]
    D -->|No| L3[Level 3<br/>Exported type<br/>unexported fields]
    D -->|Yes| L4[Level 4<br/>Exported type<br/>Exported fields]
```

## Application Design Patterns

### Supervision Structure

Applications typically have a supervision tree:

```go
// apps/worker/app.go
func (a *app) Load(args ...any) (gen.ApplicationSpec, error) {
    return gen.ApplicationSpec{
        Name: "worker",
        Group: []gen.ApplicationMemberSpec{
            {Name: "queue_manager", Factory: createQueueManager},
            {
                Name:    "processor_sup",
                Factory: createProcessorSupervisor,
                Args:    []any{a.options.Concurrency},
            },
        },
    }, nil
}

// apps/worker/supervisor.go
type processorSupervisor struct {
    act.Supervisor
}

func (s *processorSupervisor) Init(args ...any) (act.SupervisorSpec, error) {
    concurrency := args[0].(int)
    children := make([]act.SupervisorChildSpec, concurrency)
    for i := 0; i < concurrency; i++ {
        children[i] = act.SupervisorChildSpec{
            Name:    gen.Atom(fmt.Sprintf("processor_%d", i)),
            Factory: createProcessor,
        }
    }
    return act.SupervisorSpec{
        Type:     act.SupervisorTypeOneForOne,
        Children: children,
        Restart: act.SupervisorRestart{
            Strategy: act.SupervisorStrategyTemporary,
        },
    }, nil
}

func createProcessorSupervisor() gen.ProcessBehavior {
    return &processorSupervisor{}
}
```

```mermaid
graph TB
    App[Worker Application]
    App --> QM[Queue Manager]
    App --> Sup[Processor Supervisor]
    Sup --> P1[Processor 1]
    Sup --> P2[Processor 2]
    Sup --> P3[Processor 3]
```

### Configuration via Options

Applications accept configuration through an Options struct:

```go
// apps/worker/app.go
type Options struct {
    Concurrency   int
    QueueSize     int
    RetryAttempts int
    RetryDelay    time.Duration
}

func DefaultOptions() Options {
    return Options{
        Concurrency:   10,
        QueueSize:     1000,
        RetryAttempts: 3,
        RetryDelay:    time.Second,
    }
}

func CreateApp(opts Options) gen.ApplicationBehavior {
    // Validate options
    if opts.Concurrency < 1 {
        opts.Concurrency = DefaultOptions().Concurrency
    }
    return &app{options: opts}
}
```

Entry points configure options based on deployment:

```go
// cmd/worker/main.go
func main() {
    opts := worker.Options{
        Concurrency:   getEnvInt("WORKER_CONCURRENCY", 50),
        QueueSize:     getEnvInt("WORKER_QUEUE_SIZE", 10000),
        RetryAttempts: getEnvInt("WORKER_RETRY_ATTEMPTS", 5),
    }

    node, _ := ergo.StartNode("worker@host", gen.NodeOptions{
        Applications: []gen.ApplicationBehavior{
            worker.CreateApp(opts),
        },
    })
    node.Wait()
}
```

### Inter-Application Communication

Applications discover each other through application names, not node names:

```go
// apps/api/handler.go
func (h *Handler) processRequest(req Request) error {
    // Discover worker application
    routes, _ := h.Node().Network().ResolveApplication("worker")

    if len(routes) == 0 {
        return errors.New("no workers available")
    }

    // Select a worker (weighted random)
    target := h.selectRoute(routes)

    // Address the process by name and node: works whether local or remote,
    // and needs no connection handle
    result, err := h.Call(gen.ProcessID{Name: "queue_manager", Node: target.Node},
        types.ProcessTask{
            TaskID:  req.ID,
            Payload: req.Data,
        })

    return err
}
```

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:

```go
// apps/orders/manager.go
type Manager struct {
    act.Actor
    completed gen.Ref // the token RegisterEvent handed back
}

func (m *Manager) Init(args ...any) error {
    // The producer registers the event and keeps the token
    token, err := m.RegisterEvent("order.completed", gen.EventOptions{Buffer: 16})
    if err != nil {
        return err
    }
    m.completed = token
    return nil
}

func (m *Manager) completeOrder(orderID string) error {
    // ... complete order logic ...

    // Publish (Level 4 - service-level). Only the token holder can.
    return m.SendEvent("order.completed", m.completed, types.OrderCompleted{
        OrderID:     orderID,
        CompletedAt: time.Now(),
    })
}

// apps/shipping/listener.go
func (l *Listener) Init(args ...any) error {
    // The consumer subscribes by event name, not by token
    buffered, err := l.LinkEvent(gen.Event{Name: "order.completed"})
    if err != nil {
        // gen.ErrEventUnknown when the producer has not registered yet
        return err
    }
    for _, event := range buffered {
        l.handle(event)
    }
    return nil
}

func (l *Listener) HandleEvent(event gen.MessageEvent) error {
    l.handle(event)
    return nil
}

func (l *Listener) handle(event gen.MessageEvent) {
    switch e := event.Message.(type) {
    case types.OrderCompleted:
        l.createShipment(e.OrderID)
    }
}
```

Events decouple applications. Orders doesn't know who listens. Shipping doesn't know where Orders runs.

Two things the shapes above are not interchangeable about. `RegisterEvent` makes this process the **producer** and returns a `gen.Ref` token that `SendEvent` demands; a consumer never calls it. And a subscription returns the buffered events, which is how a late subscriber catches up - dropping that slice silently loses everything published before it arrived. The `Init`-time link also has an ordering hazard: if the producer has not registered yet, the link fails with `gen.ErrEventUnknown`, so a consumer that must not miss the start needs to retry rather than assume. See [Events](/basics/events.md) for the full model.

## Deployment Patterns

### Pattern 1: Development Monolith

Everything in one process for fast iteration:

```go
// cmd/dev/main.go
func main() {
    node, _ := ergo.StartNode("dev@localhost", gen.NodeOptions{
        Applications: []gen.ApplicationBehavior{
            api.CreateApp(api.Options{Port: 8080}),
            worker.CreateApp(worker.Options{Concurrency: 5}),
            storage.CreateApp(storage.Options{DSN: "dev.db"}),
            observer.CreateApp(observer.Options{Port: 9911}),
        },
    })

    node.Log().Info("Development server: http://localhost:8080")
    node.Log().Info("Observer UI: http://localhost:9911")
    node.Wait()
}
```

Benefits:

* Single binary to run
* No network setup
* Easy debugging
* Fast startup

### Pattern 2: Distributed Production

Each application on dedicated nodes:

```mermaid
graph TB
    subgraph "API Tier"
        API1["api@api-1"]
        API2["api@api-2"]
    end

    subgraph "Worker Tier"
        W1["worker@worker-1"]
        W2["worker@worker-2"]
        W3["worker@worker-3"]
    end

    subgraph "Storage Tier"
        S1["storage@storage-1"]
    end

    subgraph "Infrastructure"
        etcd["etcd cluster"]
    end

    API1 --> W1
    API1 --> W2
    API2 --> W2
    API2 --> W3
    W1 --> S1
    W2 --> S1
    W3 --> S1

    API1 -.-> etcd
    API2 -.-> etcd
    W1 -.-> etcd
    W2 -.-> etcd
    W3 -.-> etcd
    S1 -.-> etcd
```

Each binary runs one application:

```go
// cmd/api/main.go
node, _ := ergo.StartNode("api@api-1", gen.NodeOptions{
    Applications: []gen.ApplicationBehavior{
        api.CreateApp(api.Options{Port: 8080}),
    },
    Network: gen.NetworkOptions{
        Registrar: etcd.Create(etcd.Options{
            Endpoints: []string{"etcd:2379"},
            Cluster:   "production",
        }),
    },
})
```

Benefits:

* Independent scaling per tier
* Fault isolation
* Resource optimization
* Zero-downtime updates

### Pattern 3: Hybrid Deployment

Group related applications for efficiency:

```go
// cmd/frontend/main.go - API + lightweight worker
node, _ := ergo.StartNode("frontend@web-1", gen.NodeOptions{
    Applications: []gen.ApplicationBehavior{
        api.CreateApp(api.Options{Port: 8080}),
        worker.CreateApp(worker.Options{Concurrency: 5}), // light tasks
    },
    Network: gen.NetworkOptions{Registrar: registrar},
})

// cmd/backend/main.go - Heavy processing + storage
node, _ := ergo.StartNode("backend@compute-1", gen.NodeOptions{
    Applications: []gen.ApplicationBehavior{
        worker.CreateApp(worker.Options{Concurrency: 100}), // heavy tasks
        storage.CreateApp(storage.Options{DSN: "prod.db"}),
    },
    Network: gen.NetworkOptions{Registrar: registrar},
})
```

```mermaid
graph TB
    subgraph "Frontend Nodes"
        F1[API + Light Worker]
        F2[API + Light Worker]
    end

    subgraph "Backend Nodes"
        B1[Heavy Worker + Storage]
        B2[Heavy Worker + Storage]
    end

    F1 --> B1
    F1 --> B2
    F2 --> B1
    F2 --> B2
```

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:

```go
// apps/worker/processor_test.go
package worker

import (
    "testing"

    "ergo.services/ergo/gen"
    "ergo.services/ergo/testing/check"
    "ergo.services/ergo/testing/unit"
)

func TestProcessorHandlesTask(t *testing.T) {
    s, err := unit.Spawn(t, createProcessor, gen.ProcessOptions{})
    check.NoError(t, err)

    // Deliver an internal message (Level 1)
    s.SendMessage(gen.PID{}, scheduleTask{
        taskID:   "task-1",
        priority: 1,
        data:     []byte("test"),
    })

    // Assert on what the actor did, not on what it holds
    s.ShouldSend().Message(taskCompleted{
        taskID: "task-1",
        result: []byte("processed"),
    }).Once().Assert()
}
```

`unit.Spawn` runs the actor's `Init` on a mock node and hands back a `*unit.Subject`: `SendMessage`, `Call` and their name/alias variants drive it, and the `Should*` family asserts on what it did - every outbound operation is recorded rather than performed. Use `unit.Prepare` instead when the actor does something at `Init` time that has to be stubbed first.

### Integration Testing Applications

Test complete applications:

```go
// apps/worker/integration_test.go
package worker_test

import (
    "testing"
    "myproject/apps/worker"

    "ergo.services/ergo"
    "ergo.services/ergo/gen"
)

func TestWorkerApplication(t *testing.T) {
    node, err := ergo.StartNode("test@localhost", gen.NodeOptions{
        Applications: []gen.ApplicationBehavior{
            worker.CreateApp(worker.Options{Concurrency: 2}),
        },
    })
    if err != nil {
        t.Fatal(err)
    }
    defer node.Stop()

    // Verify application started
    info, err := node.ApplicationInfo("worker")
    if err != nil {
        t.Fatal(err)
    }
    if info.State != gen.ApplicationStateRunning {
        t.Fatalf("expected running, got %s", info.State)
    }

    // Send test message and verify behavior
    // ...
}
```

### Testing Distributed Scenarios

Test multiple nodes:

```go
func TestCrossNodeCommunication(t *testing.T) {
    // A port belongs in the acceptor, never in the node name: the name is
    // <name>@<host>, and a colon in it is passed to the resolver as part of
    // the host, which fails with "no such host".
    apiNode, err := ergo.StartNode("api@localhost", gen.NodeOptions{
        Applications: []gen.ApplicationBehavior{
            api.CreateApp(api.Options{}),
        },
        Network: gen.NetworkOptions{
            Cookie:    "test",
            Acceptors: []gen.AcceptorOptions{{Host: "localhost", Port: 15001}},
        },
    })
    if err != nil {
        t.Fatal(err)
    }
    defer apiNode.Stop()

    workerNode, err := ergo.StartNode("worker@localhost", gen.NodeOptions{
        Applications: []gen.ApplicationBehavior{
            worker.CreateApp(worker.Options{}),
        },
        Network: gen.NetworkOptions{
            Cookie:    "test",
            Acceptors: []gen.AcceptorOptions{{Host: "localhost", Port: 15002}},
        },
    })
    if err != nil {
        t.Fatal(err)
    }
    defer workerNode.Stop()

    // Without a registrar, tell this node where the peer listens
    if err := apiNode.Network().AddRoute("worker@localhost", gen.NetworkRoute{
        Route: gen.Route{Host: "localhost", Port: 15002},
    }, 100); err != nil {
        t.Fatal(err)
    }

    // A cross-node Call is made by a process, not by the node: spawn one and
    // let it address the peer as gen.ProcessID{Name, Node}.
    pid, err := apiNode.Spawn(createProbe, gen.ProcessOptions{})
    if err != nil {
        t.Fatal(err)
    }
    _ = pid
}
```

Two things this shape is not free to change. A node name is `<name>@<host>` and nothing else - only the name half is character-checked at start, and the host half goes to the acceptor as written, so `worker@localhost:15002` is resolved as a hostname and the node never comes back. And `gen.RemoteNode` is about the peer, not about talking to it: it offers `Spawn`, `ApplicationStart` and `Info`, so a request to a process on that node is made from a process with `Call(gen.ProcessID{Name: "queue_manager", Node: "worker@localhost"}, ...)`. For multi-node tests over the real protocol, `ergo/testing/stage` is the harness built for exactly this - see [Stage Testing](/testing/stage.md).

## Evolution and Refactoring

### Starting Simple

Begin with a monolith:

```go
// cmd/main.go - Everything together
func main() {
    node, _ := ergo.StartNode("app@localhost", gen.NodeOptions{
        Applications: []gen.ApplicationBehavior{
            combined.CreateApp(), // One big application
        },
    })
    node.Wait()
}
```

### Extracting Applications

When the monolith grows, extract bounded contexts:

**Step 1**: Identify boundaries in the combined application.

```
combined/
├── order_handler.go      → apps/orders/
├── order_processor.go    → apps/orders/
├── shipping_handler.go   → apps/shipping/
├── shipping_tracker.go   → apps/shipping/
└── ...
```

**Step 2**: Create separate application packages.

```go
// apps/orders/app.go
package orders

func CreateApp(opts Options) gen.ApplicationBehavior {
    return &app{options: opts}
}

// apps/shipping/app.go
package shipping

func CreateApp(opts Options) gen.ApplicationBehavior {
    return &app{options: opts}
}
```

**Step 3**: Update the entry point.

```go
// cmd/main.go - Multiple applications, still one node
func main() {
    node, _ := ergo.StartNode("app@localhost", gen.NodeOptions{
        Applications: []gen.ApplicationBehavior{
            orders.CreateApp(orders.Options{}),
            shipping.CreateApp(shipping.Options{}),
        },
    })
    node.Wait()
}
```

**Step 4**: When ready, create distributed entry points.

```go
// cmd/orders/main.go
func main() {
    node, _ := ergo.StartNode("orders@orders-1", gen.NodeOptions{
        Applications: []gen.ApplicationBehavior{
            orders.CreateApp(orders.Options{}),
        },
        Network: gen.NetworkOptions{Registrar: registrar},
    })
    node.Wait()
}
```

The application code never changes. Only entry points and deployment.

### Merging Applications

If you over-distributed:

```go
// Before: Two binaries
// cmd/orders/main.go → orders.CreateApp()
// cmd/shipping/main.go → shipping.CreateApp()

// After: One binary
// cmd/fulfillment/main.go
func main() {
    node, _ := ergo.StartNode("fulfillment@host", gen.NodeOptions{
        Applications: []gen.ApplicationBehavior{
            orders.CreateApp(orders.Options{}),
            shipping.CreateApp(shipping.Options{}),
        },
    })
    node.Wait()
}
```

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:

```
apps/
├── orders/      # Complete order lifecycle
├── shipping/    # Complete shipping lifecycle
└── inventory/   # Complete inventory management
```

Bad:

```
apps/
├── order_api/           # Just API handlers
├── order_service/       # Just business logic
├── order_repository/    # Just data access
└── order_events/        # Just events
```

### 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 to external resources (connections, files) 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

## What's Next

This article covered project organization for flexible deployment. As your system grows into a distributed cluster, two topics become essential:

* [Building a Cluster](/advanced/building-a-cluster.md) - service discovery, load balancing, failover, and observability
* [Message Versioning](/advanced/message-versioning.md) - evolving message contracts during rolling upgrades
