Dual-Binary Architecture with Shared Management Layer

Web & API Engineering intermediate Building with AI ~25 min read

Overview

As SaaS platforms grow, a single monolithic binary often becomes a bottleneck—different products have different dependency profiles, deployment cadences, and resource requirements. The dual-binary architecture addresses this by compiling two distinct executables (a search binary and a cloud binary) from a shared codebase, each containing product-specific features while reusing common infrastructure like authentication, RBAC, billing, and audit logging.

This pattern is fundamentally about managing the tension between code reuse and deployment independence. You want a single source of truth for cross-cutting concerns (auth, billing, tenant management) so that a security fix ships to both products simultaneously. But you also want each product to carry only its own dependencies, start only its own services, and be deployable on its own schedule without risking breakage in the other product.

As a concrete example, a platform with multiple products can implement this architecture using Uber’s fx dependency injection framework. The shared management layer provides modules for auth, RBAC, billing, and audit that both binaries compose into their dependency graphs, while product-specific modules (search indexing for the search binary, cloud instance management for the cloud binary) are only included in their respective binaries.

Core Concepts

Dependency Injection with Uber fx

Uber’s fx framework provides compile-time dependency injection for Go applications. Each binary defines its own fx.App that composes a set of modules—shared modules plus product-specific modules. The framework resolves the dependency graph at startup, ensuring all required services are provided. If a product-specific module requires a service that the other product’s module provides, the build simply won’t compile, catching integration errors early.

Shared Management Modules

The management layer consists of fx.Module definitions for cross-cutting concerns: authentication middleware, RBAC policy enforcement, Stripe billing integration, and structured audit logging. These modules export well-defined interfaces that product-specific code depends on. Because they’re compiled into both binaries, a security patch to the auth module automatically ships with the next release of either product.

Build Tag Separation

Go build tags provide an additional mechanism for separating product-specific code at compile time. Files tagged //go:build search are only compiled into the search binary; files tagged //go:build cloud are only compiled into the cloud binary. This prevents accidental inclusion of heavy dependencies (e.g., ML inference libraries) in the binary that doesn’t need them, keeping binary size and startup time optimized for each product.

Interface-Driven Boundaries

The shared management layer defines Go interfaces for each service boundary (e.g., Authenticator, BillingReporter, AuditLogger). Product-specific code depends only on these interfaces, never on concrete implementations. This enables testing product code with mock implementations and allows the shared layer to swap implementations (e.g., switching from Stripe to a different billing provider) without modifying product code.

How It Works

The architecture is organized around three layers: shared modules, product-specific modules, and binary entry points. Each binary’s main.go composes these layers using fx.New.

Shared Module Definitions

package shared

import (
    "go.uber.org/fx"
)

// AuthModule provides authentication and session management
var AuthModule = fx.Module("auth",
    fx.Provide(NewJWTValidator),
    fx.Provide(NewSessionStore),
    fx.Provide(NewAuthMiddleware),
)

// RBACModule provides role-based access control
var RBACModule = fx.Module("rbac",
    fx.Provide(NewPolicyEngine),
    fx.Provide(NewRBACMiddleware),
)

// BillingModule provides Stripe integration
var BillingModule = fx.Module("billing",
    fx.Provide(NewStripeClient),
    fx.Provide(NewUsageCollector),
    fx.Provide(NewMeterReporter),
    fx.Provide(NewWebhookHandler),
)

// AuditModule provides structured audit logging
var AuditModule = fx.Module("audit",
    fx.Provide(NewAuditLogger),
    fx.Provide(NewAuditMiddleware),
)

// ManagementLayer bundles all shared modules
var ManagementLayer = fx.Options(
    AuthModule,
    RBACModule,
    BillingModule,
    AuditModule,
)

Product-Specific Modules

//go:build search

package search

import (
    "go.uber.org/fx"
)

// SearchModule provides search-specific services
var SearchModule = fx.Module("search",
    fx.Provide(NewIndexManager),
    fx.Provide(NewQueryEngine),
    fx.Provide(NewSearchRouter),
    fx.Provide(NewEmbeddingPipeline),
)

// SearchLifecycle hooks for graceful startup/shutdown
var SearchLifecycle = fx.Invoke(func(lc fx.Lifecycle, idx *IndexManager) {
    lc.Append(fx.Hook{
        OnStart: func(ctx context.Context) error {
            return idx.WarmCaches(ctx)
        },
        OnStop: func(ctx context.Context) error {
            return idx.FlushAndClose(ctx)
        },
    })
})
//go:build cloud

package cloud

import (
    "go.uber.org/fx"
)

// CloudModule provides cloud instance management
var CloudModule = fx.Module("cloud",
    fx.Provide(NewInstanceProvisioner),
    fx.Provide(NewStateMachine),
    fx.Provide(NewCloudRouter),
    fx.Provide(NewResourceMonitor),
)

// CloudLifecycle hooks for graceful startup/shutdown
var CloudLifecycle = fx.Invoke(func(lc fx.Lifecycle, mon *ResourceMonitor) {
    lc.Append(fx.Hook{
        OnStart: func(ctx context.Context) error {
            return mon.StartPolling(ctx)
        },
        OnStop: func(ctx context.Context) error {
            return mon.StopPolling(ctx)
        },
    })
})

Binary Entry Points

//go:build search

package main

import (
    "myapp/shared"
    "myapp/search"
    "go.uber.org/fx"
)

func main() {
    app := fx.New(
        shared.ManagementLayer,
        search.SearchModule,
        search.SearchLifecycle,
        fx.Provide(NewSearchConfig),
    )
    app.Run()
}
//go:build cloud

package main

import (
    "myapp/shared"
    "myapp/cloud"
    "go.uber.org/fx"
)

func main() {
    app := fx.New(
        shared.ManagementLayer,
        cloud.CloudModule,
        cloud.CloudLifecycle,
        fx.Provide(NewCloudConfig),
    )
    app.Run()
}

Practical Applications

As a concrete application, this pattern enables shipping two distinct SaaS products—a vector search service and a cloud management platform—from a single repository. Both products share identical authentication flows, billing integration, and audit trails, which matters for enterprise customers who need consistent security and compliance posture across the entire platform.

The pattern is especially valuable during compliance audits: auditors can verify the auth implementation once and know it applies to both products. Similarly, when a CVE affects a shared dependency, a single patch propagated through the management layer secures both binaries simultaneously.

Deployment pipelines build both binaries from the same commit but deploy them independently. The search binary can be rolled out to staging while the cloud binary remains on the previous version, enabling per-product canary deployments without diverging the shared code.

Implementation Patterns

Feature Flags per Binary

package shared

import "os"

type ProductID string

const (
    ProductSearch ProductID = "search"
    ProductCloud  ProductID = "cloud"
)

type FeatureConfig struct {
    ProductID       ProductID
    EnabledFeatures map[string]bool
}

func NewFeatureConfig() *FeatureConfig {
    product := ProductID(os.Getenv("PRODUCT_ID"))
    cfg := &FeatureConfig{
        ProductID:       product,
        EnabledFeatures: make(map[string]bool),
    }

    switch product {
    case ProductSearch:
        cfg.EnabledFeatures["vector_search"] = true
        cfg.EnabledFeatures["embedding_pipeline"] = true
    case ProductCloud:
        cfg.EnabledFeatures["instance_provisioning"] = true
        cfg.EnabledFeatures["resource_monitoring"] = true
    }

    // Shared features enabled for both
    cfg.EnabledFeatures["billing"] = true
    cfg.EnabledFeatures["audit_log"] = true
    cfg.EnabledFeatures["rbac"] = true
    return cfg
}

Shared Interface Contracts

package shared

import "context"

// Authenticator is implemented by the shared auth module
// and consumed by both product binaries
type Authenticator interface {
    ValidateToken(ctx context.Context, token string) (*Claims, error)
    RefreshToken(ctx context.Context, refreshToken string) (string, error)
}

// BillingReporter is consumed by product code to report usage
type BillingReporter interface {
    RecordUsage(ctx context.Context, customerID string, meter string, value int64) error
    GetCurrentUsage(ctx context.Context, customerID string, meter string) (int64, error)
}

// AuditLogger is consumed by product code to log auditable events
type AuditLogger interface {
    Log(ctx context.Context, event AuditEvent) error
    Query(ctx context.Context, filter AuditFilter) ([]AuditEvent, error)
}

Common Pitfalls

Further Reading