โThe Zen of the Effect-ive Gopherโ โ calm, centered, and side-effect free.
Effect-ive Go is the first attempt to implement Effect-ive Programming idiomatically in Go. This project provides a systematic way to isolate and handle side effects on top of Goโs core principlesโgoroutines, channels, context, and duck typing.
Effect-ive Programming is an approach to building predictable, testable, and reusable code.
Its first-class citizen is the Effect Pattern: the idea is to identify impure parts of your logic (=side effects), and isolate and delegate them externally.
This is a pattern-oriented approach, not tied to any specific language or paradigm, making it applicable in any environment.
Effect-ive Programming generally follows three steps:
- ๐ Side effect recognition: Identify non-pure logic
- ๐งญ Side effect isolation: Isolate it into an external handler
- ๐ฌ Side effect delegation: Delegate the effect to the handler
In real-world applications, side effects are everywhereโlogging, reading config, managing shared state, spawning goroutines, or interacting with the network. These effects make our code:
-
harder to test
-
difficult to reason about
-
fragile to reuse or compose
Effect-ive Programming offers a systematic way to isolate and delegate those side effects, so your business logic remains:
-
predictable โ no hidden interactions or surprises
-
testable โ you can mock or replace effects freely
-
reusable โ pure logic is portable across contexts
By explicitly recognizing effects and pushing them to the boundary of your application, you enforce separation of concerns and avoid coupling your core logic to runtime behavior.
Even in languages like Goโwithout native effect systemsโyou can regain this structure through simple conventions and disciplined handler design.
This is what Effect-ive Go makes possible.
โ Before / After examplesโ Practical issues in Go: DI, error handling, config, context propagation, etc.(TBD)
- Executed immediately upon call
- Deterministic: Output depends only on input
- Do nothing besides returning the output (no side effects)
- Everything that is not a pure function
- A helpful guide: the 4W Checklist โ if you violate even one, itโs a side effect:
โ An effect is anything that violates Who, When, Where, or What.
An effect arises when a function violates one or more of the following 4W guarantees. Each type of violation leads to a specific class of side effect.
4W Criteria | Effect Type | Description | Examples |
---|---|---|---|
Who: Control Flow OwnershipโIs someone else executing this?โ | Concurrency Effect | Delegates execution to another unit of control flow (thread, goroutine, etc.) | go func() { ... } |
When: Execution Timing GuaranteeโIs execution guaranteed immediately?โ | Task Effect | Execution may happen later, depending on the runtime or external trigger | http.Get(...) |
Where: Context AwarenessโWhich context is this running in?โ | Binding / State Effect | Behavior depends on the current scoped context or environment | context.WithValue() request.Context() ThreadLocal this(JavaScript) |
What: Predictable ResultโDoes the same input always produce the same output?โ | Time / Random / IO Effect | Depends on or mutates internal/external state | rand.Intn(n) time.Now() globalCounter++ db.Save() |
The purpose of the effect pattern is simple:
๐ฌ Delegate side effects to external handlers, so your logic remains predictable, testable, and reusable.
- If even one of the 4Ws is violated, it qualifies as a side effect.
- But not every side effect should be turned into a new effect.
If a side effect can be composed using existing effects + pure functions, do not define a new effect.
- Each effect is handled by a dedicated effect handler
- Every handler has a clear scope:
- Only the closest handler is applied โ no implicit propagation
- Leaving the scope automatically shuts down the handler
- Handlers must not leak state outside their scope
- Effect scope should be close to where effects are performed
- Avoid placing all handlers at the composition root
Delegation is more than passing data โ ๐ itโs about transferring control flow ownership to the handler.
Type | Description |
---|---|
Resumable Effect | The handler processes the effect and returns the result to the initiator, who waits for the handling to complete before resuming. |
Fire-and-Forget | The initiator triggers the effect and resumes immediately, without waiting for the handler to finish processing. |
Abortive Effect | The handler processes the effect and terminates the initiator's flow, e.g., by raising a panic or error that escapes the current scope. |
- An effect is any logic that depends on context, has external interaction, or violates pure function guarantees
- The Effect Pattern provides a structured way to recognize, isolate, and delegate them
- It forms a core foundation for testable, modular, reusable architecture
Effect-ive Go is designed around the idea of delegating side effects to dedicated handlers. Its core structure can be broken down into three main components:
Each handler is responsible for handling a specific type of side effect. All handlers satisfy the following conditions:
- Scoped: Handlers are only active within a specific context
- Channel-based message handling: Handlers run in goroutines and communicate through channels
- Explicit lifecycle management: Use
WithXxxEffectHandler(ctx)
to create anddefer cancel()
to release - ๐ Not thread-safe by design: Handlers are meant to be used only within a single goroutine to ensure proper scoping
Each handler is bound to a specific context.
- Within that context, the effect is enabled
- Once the context is exited, the handler is automatically shut down, and no effects can be handled
This guarantees:
- โ No handler leaks
- โ No upward propagation of effects (only explicit delegation is allowed)
- โ Handler lifecycle is tracked via context
Effect-ive Go supports two main delegation models: resumable and fire-and-forget.
Type | Description | Examples |
---|---|---|
Resumable Effect | Processes the effect and returns the result | Reading config, state access |
Fire-and-Forget | Executes asynchronously without waiting for a result | Logging, sending events |
โ Both models are implemented using goroutines + channels, and delegation always involves handing off control flow ownership at the point of perform
.
1. A handler scope is created with `WithStateEffectHandler(ctx)`
2. Internally, a handler goroutine is spawned and waits on a `chan`
3. Domain logic calls `PerformResumableEff(ctx, EffectState, payload)`
4. Handler processes the effect and sends result back on `resumeCh`
5. Caller resumes execution with the result
Another important role of Effect-ive Programming is to explicitly surface all effects that occur within a given function. In statically-typed languages with strong type inference, not only the kind of effects, but even their payload types can be reflected directly in function signatures.
In monadic systems, effects are expressed in the return type. In handler-based systems, effects are reflected by the presence of handler types in the context or surrounding scope.
This means that every function, from the one that performs the effect to the one that handles it, must reveal the effect type in its signature. However, Go lacks certain language features that make this kind of typing feasible:
- โ No sum types (tagged unions)
- โ No annotations or decorators to mark effect usage
- โ Function signatures canโt express anything beyond parameters and return values
โ Effect-ive Go introduces two complementary strategies:
A proposed tool will statically analyze the call stack from effect site to handler, and inject comments like this:
// @effect: Log, State, Config
func MyServiceFunc(...) { ... }
At the composition root, if these comments remain, it means the handler for that effect is missing.
If an effect is performed without a handler in scope, the ideal behavior would be a compile-time failure. Since Go doesnโt support this, Effect-ive Go panics at runtime instead.
This โdie loudlyโ approach ensures that missing handlers are detected early during development and testing.
But caution: In rarely executed branches, an unhandled effect might sneak into production. Thatโs why careful code review and testing is essential when using dynamic effect systems in Go.
- Handlers are registered into context using EffectEnum as a key
- Hash partitioning is supported via the
Partitionable
interface - Designed to be panic-safe, explicitly terminated, and single-threaded for maximum predictability
โ Guide for users to define their own effect handlersโ How to apply scope and implement custom logic
While building your own custom effects, especially ResumableEffect
, keep the following principle in mind:
โ ๏ธ Avoid invoking resumable effects inside other effect handlers.
Resumable effects involve not just side-effect execution but also waiting for a response. If a handler itself triggers another resumable effect, you introduce:
-
โ Coupling between effects
-
โ Non-determinism in handler behavior
-
โ Hidden control flow delegation, which breaks testability and predictability
This is similar to avoiding "nested monads" in functional programming. Instead of:
// โ Don't do this inside an effect handler
msg.ResumeCh <- PerformResumableEff(ctx, EffectConfig, payload)
Do this instead:
// โ
Do it outside and pass as explicit input
configValue := PerformResumableEff(ctx, EffectConfig, payload) PerformResumableEff(ctx, MyEffect, configValue)
Keep your effects flat.
Handlers should not rely on other handlers to work correctly.
Let the caller take control of effect composition:
-
Scope the config effect handler first
-
Query the value
-
Then pass it to the other effect handler as an input
Fire-and-Forget effects (e.g., logging) are safe to call inside other handlers.
// โ
This is fine inside any handler
LogEff(ctx, LogInfo, "processing payload", map[string]any{"key": payload.Key})
Because they do not block and do not depend on return values, theyโre harmless and often useful for internal visibility.
Effect | Type | Handler Responsibility |
---|---|---|
๐ง State |
Resumable | Manage key-value state across goroutines Sharded with Partitionable |
๐ Binding |
Resumable | Lookup config/flags/envs, with upper-scope fallback |
๐งต Concurrency |
Fire-and-Forget | Spawn goroutines, gracefully terminate on cancel |
๐ Log |
Fire-and-Forget | Async log emission via zap logger; ordering via numWorkers = 1 |
apiKey := os.Getenv("API_KEY")
if apiKey == "" {
log.Fatal("missing API_KEY")
}
apiKey, err := GetFromBindingEffect[string](ctx, "API_KEY")
if err != nil {
LogEff(ctx, LogError, "missing binding", map[string]any{"key": "API_KEY"})
return err
}
log.Printf("Processing user %s", userID)
LogEff(ctx, LogInfo, "processing user", map[string]any{"userID": userID})
cache := make(map[string]any)
cache["session"] = sessionData
StateEff(ctx, SetStatePayload{Key: "session", Value: sessionData})
go func() {
process(userID)
}()
ConcurrencyEff(ctx, []func(context.Context){
func(ctx context.Context) { process(userID) },
})
Some languages treat exceptions or errors as effectsโan unexpected break from the normal execution flow. But Go's approach is fundamentally different, which is why Effect-ive Go does not provide a built-in RaiseEffect
.
-
โ In Go, errors are values.
Errors are part of the regular return values (
val, err := ...
). They're not magical, and they donโt break flow like exceptions. -
โ Go supports multiple return values.
Unlike most languages (Java, Python, etc.) that return only a single value, Go can return a result and an error side-by-side. This removes the need for an external
RaiseEffect
. -
โ Error handling is not an effect in Go's model.
Effects typically represent external interactions or non-determinism (e.g., time, I/O, logging). In contrast, Go treats errors as part of deterministic flow: they propagate up as values.
-
โ Errors in Go are not "events".
Theyโre not thrown and caught like in other languages. They're wrapped, enriched with context, and passed up the call chain explicitly.
So, while effect systems in other languages (like Kotlin or OCaml 5) must model error propagation as an effect, Go's idioms make this unnecessary.
๐ If your app is raising exceptions as control flow, reconsider. In Go, explicit error returns remain idiomaticโand Effect-ive Go encourages this clarity.
- Shares the same goal as FP: isolating impurity for predictable, testable code
- Does not enforce everything to be pure functions chained by composition
- Focuses on recognition and delegation of effects through Effect Pattern, not the implementation style
- Monads chain effectful computations but introduce complexity
- Composing multiple effects (e.g.,
Eff1[Eff2[T]]
) breaks intuition - Algebraic Effect Handlers emerged to fix this
- Effect-ive Programming offers a pragmatic and idiomatic alternative
Aspect | Effect-ive Programming | EH-Oriented Programming |
---|---|---|
Goal | Practical side effect isolation (SoC, testability, reuse) | Algebraic Effect abstraction at compiler level |
Handler Role | Scope-bound isolation using goroutine/context/channel | Control flow rewiring via CPS |
Implementation | Stays idiomatic (Go-specific) | Requires new features (CPS, algebraic effect) |
Design Limit | Stays within language boundaries | Needs runtime/compiler support |
Recognition | Based on 4W: Who, What, When, Where | Transformed effect contexts |
Use Cases | Real-world concerns (log, state, cache, config, concurrency) | Language effects (exceptions, yield, fibers) |
Using effect systems like a DI container is a common anti-pattern.
๐ You should not return a service โ just request an action and receive the result.
svc := PerformEff(ctx, EffectDependency)
return svc.DoSomething()
result := PerformEff(ctx, EffectDoSomething, input)
return result
Effect systems are not about injecting helpers โ theyโre about requesting side effects and receiving results.
๐ง Don't โget something to do the jobโโinstead, โask for the job to be done.โ
๐งโโ๏ธ Effect-ive Go follows โThe Zen of Goโ Designed with simplicity, clarity, and maintainability at heart. From scope lifecycle to panic safety, every decision is deliberate. Because Go deserves idiomatic effect handling โ with Zen.