Framework
ergo-services/ergo avatar
ergo-services/ergo

Ergo Framework: the actor model for Go, with network transparency

An actor-based Framework with network transparency for creating event-driven architecture in Golang. Inspired by Erlang. Zero dependencies.

4,664 stars191 forksGoMIT

At a glance

What is it?
Ergo is a Go actor framework modelled on Erlang/OTP with no external dependencies. Its mailbox guarantees, cluster messaging, Observer tooling and benchmark numbers are worth reading closely.
Who is it for?
The concrete next step for anyone evaluating Ergo is to start a node with ergo.StartNode and spawn the Counter actor exactly as the README does, then read its HandleMessage: if your instinct on seeing a counter with no mutex is that a lock is missing somewhere, that reaction is the thing to check, because the mailbox ordering is the guarantee the framework is selling.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 29 days ago.
What is it written in?
Mainly Go, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on September 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The one guarantee the whole framework rests on

The README states the case for an actor framework in a blunt way: goroutines and channels work well until a system grows, and then come the mutexes, the race conditions, the service discovery configs, the retry logic and the connection pool management. Ergo replaces that list with one model, isolated processes that communicate through messages, supervised automatically, and addressable across any cluster.

The smallest example in the README is a counter, and it is worth reading closely because the absence of a lock in it is the entire point.

go
type Counter struct {
    act.Actor
    count int
}

type MessageInc struct{}

func (c *Counter) HandleMessage(from gen.PID, msg any) error {
    switch msg.(type) {
    case MessageInc:
        // safe without locks even with thousands of concurrent senders:
        // messages are processed one at a time
        c.count++
        c.Log().Info("count: %d", c.count)
    }
    return nil
}

The inline comment is the design statement: safe without locks even with thousands of concurrent senders, because messages are processed one at a time. An actor is a goroutine with a mailbox in front of it, and the runtime guarantees the actor only handles one message at a time. Race conditions inside a process therefore have nowhere to occur, which is why the README's comparison table can claim they are impossible within a process rather than merely unlikely.

Two details in the example are worth noting for Go developers. The actor embeds act.Actor, which is how it gets its Log() method rather than taking a logger as a field. And HandleMessage takes both a sender PID and a message of type any, meaning dispatch is a type switch in ordinary Go code rather than a registration step in a framework DSL. A new message type is a new Go struct and another case clause.

Spawning a process and messaging it locally or remotely

The rest of the example covers startup and messaging, and the point of it is that the same three lines work whether the actor lives in the same process or on another machine.

go
// Start a node and spawn the actor
node, _ := ergo.StartNode("mynode@localhost", gen.NodeOptions{})
pid, _ := node.Spawn(factory_Counter, gen.ProcessOptions{})

// Same API whether local or on another continent
node.Send(pid, MessageInc{})
node.Send(pid, MessageInc{})

A node has a name in an Erlang-like name@host form, here mynode@localhost. ergo.StartNode takes options and returns a node and an error. Spawn takes a factory function rather than a value, returning a PID, which is the actor's address within the cluster.

That the README comments the Send calls as the same API whether local or on another continent is doing real work in that snippet. In an ordinary Go system, sending a message to a process in another machine means a client library, a serialisation format, a connection and an error path for each of those. Here the address is the abstraction, so moving an actor to another node is not a rewrite.

The factory function is worth a second look too. The README defines it as a one-line function returning a gen.ProcessBehavior. Behaviour, not state, is what you supply, so the framework controls instantiation and lifetime. That is what makes supervision trees possible: a supervisor can restart a failed actor because it knows how to construct a fresh one.

Zero dependencies is a checkable claim, and it holds

The repository description ends with three words: Zero dependencies. Inspired by Erlang. Zero external dependencies is repeated in the README as Pure Go, and it is worth checking rather than taking on faith, because the whole module file is short.

code
module ergo.services/ergo

go 1.21

There is no require block and no indirect block. For a framework that claims built-in service discovery, network transparency, distributed pub/sub and a web UI, that is a striking absence, and it tells you something about how those features are built: on the Go standard library rather than on third-party transport or serialisation packages.

The declared Go version, 1.21, is a floor rather than a ceiling, and it sets the practical constraint on adoption. Anyone on a current Go toolchain is fine; anyone pinned below 1.21 is not.

The root directory listing backs up the size of the undertaking. act/ is the actor behaviour and base implementation, app/ is the application layer, gen/ is the generation and interface definitions actors depend on, net/ is networking, node/ is node lifecycle, lib/ is libraries, meta/ is metadata, testing/ holds the benchmarks and now the testing framework, and there are root files for the package entry point, profiling and version. That is a full framework layout, achieved without a single external module.

What the benchmark numbers actually measured

The README publishes benchmark figures and, unusually, explains the methodology and the hardware behind them. Reported headline numbers are 25M or more messages per second locally, about 5.8M per second over the network, and 2.9M msg/sec for distributed pub/sub delivery to one million subscribers across ten nodes.

The methodology matters more than the headline. The README says the numbers come from make bench, which measures four scenarios: one process sending to one process, and one pair per CPU, each on a single node and across a connection between two nodes. It also defines msg/sec explicitly, as the rate messages are carried end to end including the send loops, not the rate at which Send is called. That distinction rules out the most common way these numbers get inflated.

The Threadripper run reports the highest figures.

code
BenchmarkLocal11-64        14633359     459.0 ns/op    2178526 msg/sec     58 B/op    2 allocs/op
BenchmarkLocalNN-64       143445265      39.08 ns/op  25591080 msg/sec     69 B/op    2 allocs/op
BenchmarkNetwork11-64       6348997     908.9 ns/op    1100193 msg/sec    776 B/op    6 allocs/op
BenchmarkNetworkNN-64      34500532     172.2 ns/op    5807413 msg/sec    150 B/op    6 allocs/op

The README's own reading of the two machines is careful and worth repeating. A single pair costs 191ns per message on the M4 Max against 459ns on the Threadripper, which is per-core speed. Aggregate throughput goes the other way, 25.6M msg/sec against 15.4M, because there are 64 threads to fill instead of 14. What does not move across either machine is the allocation count: two allocations per local message and six per message that crosses the network.

The README also flags a detail in the raw output that would otherwise confuse readers. The cpu line of the Linux run reports QEMU Virtual CPU version 2.5+, because that is the hypervisor string, while the hardware underneath is the Threadripper. Being told to distrust a benchmark line, in the project's own documentation, is a good sign about the rest. The Makefile supports the comparison properly too, with a bench-stat target that runs the benchmarks a set number of times and summarises through benchstat, keeping samples in BENCH_OUT so a later run can be diffed against a baseline file.

Cluster-wide pub/sub that charges per node, not per subscriber

The distributed pub/sub example is the most interesting API decision in the README, because it changes where the cost lands.

go
// Producer on any node
token, _ := producer.RegisterEvent("prices", gen.EventOptions{})
producer.SendEvent("prices", token, PriceUpdate{Asset: "BTC", Price: 95000})

// Subscriber on any other node, identical API
process.MonitorEvent(gen.Event{Name: "prices", Node: "producer@host"})

A producer registers an event once and gets a token. It then sends events against that token. A subscriber on a different node monitors the event by name and node. The README summarises the cost model in a single sentence: one million subscribers across ten nodes cost ten network messages, not one million. The framework delivers one network message per node and fans out locally from there.

That is the difference between a broker that brokers and a framework that multicasts. It also means the guarantee you get is tied to node boundaries, which is worth understanding before you design around it: a subscriber connecting and disconnecting, or a node joining and leaving, is an event the runtime has to handle at the point where it stops being a per-node problem.

The README also lists four priority queues per mailbox with guaranteed delivery and no dropped messages, under the heading of what you can build with it. Per-actor mailboxes with priorities are what make the framework usable for the two other listed patterns: WebSocket connections each becoming an addressable actor, so any node can push to any specific client with no pub/sub intermediary in between, and IoT deployments with one actor per device.

Version numbering, releases and the observability story

The release tags look strange until you read them alongside the release names. The newest tag is v1.999.330, and its name field is v3.3.0, published on 4 September 2026. Before it, v1.999.320 in February 2026, and v1.999.310 in September 2025. The 999 component is a deliberate device for ordering releases ahead of a stable 1.0 while still allowing semantic-version ordering to work, so the tag and the version you import are deliberately different strings.

The v3.3.0 release notes are written around visibility and verification. Distributed tracing propagates a trace context across Send, Call, spawn and every hop a message takes between nodes, with configurable sampling and cross-node clock-skew correction, and a new application called Pulse exports the collected spans over OTLP to Tempo, Jaeger or anything else that speaks OTLP, shipping with a Grafana dashboard.

The testing framework is the other headline. ergo.services/ergo/testing provides four layers on one fluent assertion grammar: unit runs a single actor against a mock node, stage runs real nodes over the real protocol, mock provides standalone gen interface mocks for code that consumes those interfaces, and check is the assertion core the layers share. Assertions cover messages, calls, spawns, links, monitors and events, and the notes say you can assert positively and negatively.

Observer is the third piece, a real-time web UI for monitoring and inspecting Ergo nodes. The README lists what the process view exposes: state, mailbox depth, latency, running time, wakeups and uptime, with per-process inspection of supervision tree, links, monitors, aliases, environment and internal actor state. It has been extended into a cluster inspector, and it serves an MCP endpoint, which is how an AI assistant such as Claude Code or Cursor can inspect a running cluster. The release notes also record that Saturn, a related project, became MIT from this release onward, so every module of the ecosystem is free for production use. The project is MIT licensed, has roughly 4,600 stars, and its documentation is hosted on GitBook at docs.ergo.services.

Editorial conclusion

The concrete next step for anyone evaluating Ergo is to start a node with ergo.StartNode and spawn the Counter actor exactly as the README does, then read its HandleMessage: if your instinct on seeing a counter with no mutex is that a lock is missing somewhere, that reaction is the thing to check, because the mailbox ordering is the guarantee the framework is selling.

Frequently asked questions

Does Ergo Framework have any external dependencies?

The go.mod file for the module ergo.services/ergo declares a Go version of 1.21 and contains no require block, which means nothing outside the Go standard library. The repository description states zero dependencies and the README repeats that it is zero external dependencies, pure Go.

How does Ergo avoid race conditions without mutexes?

Each actor is a process with a mailbox, and the runtime guarantees that an actor handles one message at a time. The counter example in the README increments a plain int field with no lock, with a comment noting it stays safe even with thousands of concurrent senders because messages are processed one at a time.

What performance does the Ergo Framework README report?

The README reports 25M or more messages per second locally and about 5.8M per second over the network, from a make bench target that measures four scenarios and defines msg/sec as the end-to-end rate including send loops. It notes 2 allocations per local message and 6 per network message on both an AMD Threadripper 3970X and an Apple M4 Max.

How do distributed events reach subscribers on other nodes?

A producer registers an event once to get a token, then sends events against that token. Subscribers monitor the event by name and node. The README states the framework delivers one network message per node rather than per subscriber, so one million subscribers across ten nodes cost ten network messages.

Official sources

  1. ergo-services/ergo on GitHub
  2. License: MIT
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/ergo-services-ergo.svg)](https://hysenlabs.com/projects/ergo-services-ergo)