Skip to content

Core Concepts

This guide explains the core building blocks of Fountain: the application singleton, the runnable lifecycle and its ordering, the runnable kinds you register, core controllers and their observer pattern, the two library integration patterns, and the conventions the framework follows. Understanding these concepts is the foundation for everything else in the framework.

The Fountain singleton

Fountain is bootstrapped through a singleton Fountain struct. You obtain it with New(), register the components you want to run using fluent builder methods, and start the lifecycle with Serving(). New() returns the same instance on every call (it is constructed once via sync.Once), so the configuration you accumulate through the builder methods all applies to one shared application.

The builder methods register the four runnable kinds and return *Fountain so calls can be chained:

  • WithAppInstances(appInstances ...runnable.AppInstance) — long-running servers.
  • WithInvokers(invokers ...runnable.Invoker) — one-shot initialization functions.
  • WithJobs(jobs ...runnable.JobInstance) — one-shot jobs.
  • WithCrons(crons ...runnable.JobInstance) — scheduled jobs.

Serving() is the blocking entry point that runs the registered components in order and keeps the process alive until a shutdown signal arrives.

go
fountain.New().
    WithAppInstances(httpServer, grpcServer).
    WithInvokers(initDB).
    WithCrons(dailyReport).
    Serving()

Lifecycle & ordering

When you call Serving(), Fountain runs the registered kinds in a fixed order:

Invokers → Jobs → Crons → AppInstances → Auxiliary

  1. Invokers run first. They execute concurrently when the concurrency flag is on, otherwise sequentially.
  2. Jobs run next, following the same concurrency rule as invokers.
  3. Crons run after that. When crons are present, both the concurrency and hang flags are automatically enabled so they run in parallel and keep the application alive.
  4. AppInstances are then initialized and served. concurrency and hang are enabled here as well so the servers can run in parallel and keep the process running continuously.
  5. Auxiliary server (metrics / governance) starts when hang is enabled, keeping the application running until a shutdown signal (shutdownSignals, e.g. SIGTERM / SIGINT) is received.

Two flags govern this behavior:

  • concurrency — affects invokers and jobs. When on, those components are launched in goroutines (coordinated with a sync.WaitGroup) and run in parallel. When off, they run sequentially: each component must finish before the next starts.
  • hang — keeps the application in a waiting state rather than exiting immediately after work completes. It is typically paired with concurrency so the process waits for a system shutdown signal. When hang is off, Serving() does not wait and exits as soon as the registered work finishes — suitable for short, fast tasks.

On shutdown, Fountain runs beforeStopClean functions, stops jobs and crons, destroys instances, then runs afterStopClean functions.

Runnable kinds

The components you register implement interfaces defined in the runnable package.

AppInstance

An AppInstance is a long-running server (an HTTP, gRPC, TCP, UDP, or QUIC server). Fountain initializes it, runs Serving() in a goroutine, and destroys it on shutdown. The interface is:

go
// AppInstance type;
type AppInstance interface {
    Initialize() error
    // Serving was called in a goroutine
    Serving()
    Destroy() error
    OnConfigChange()
    Info() ServerInstance
}

Initialize() prepares the instance, Serving() runs the server (invoked in a goroutine, so it may block), Destroy() releases resources on shutdown, OnConfigChange() is the callback fired when configuration changes, and Info() returns the ServerInstance metadata (name, ID, protocol, address, and so on) used for things like service discovery.

Invoker

An Invoker is a one-shot initialization function — the simplest runnable kind. It is just a function that returns an error:

go
type Invoker func() error

Invokers run first in the lifecycle, making them the right place for setup work such as connecting to a database or warming a cache.

JobInstance

A JobInstance is a one-shot or scheduled unit of work. Both WithJobs(...) (one-shot jobs) and WithCrons(...) (scheduled jobs) accept JobInstance values. The interface is:

go
// JobInstance type;
type JobInstance interface {
    Start() error
    GetName() string
    Stop() error
}

Start() runs the job, GetName() identifies it, and Stop() is called during shutdown.

Core controllers & the observer pattern

Business logic in Fountain is organized into core controllers. A controller implements the CoreController interface from the core package:

go
// CoreController interface
type CoreController interface {
    InstallController()
    RegisterCallback(cb any)
}

You register controllers with core.RegisterCoreController(&MyController{}), then wire them up with core.InstallCoreControllers() once their dependencies (databases, caches, and so on) are available. During installation, Fountain calls InstallController() on each controller and then calls RegisterCallback(...) on each controller for every other controller, allowing controllers to discover one another.

go
core.RegisterCoreController(&UserController{})
core.RegisterCoreController(&OrderController{})

// after databases, caches, etc. are installed:
core.InstallCoreControllers()

Controllers communicate through an observer pattern. A controller that wants to receive events implements the generic CoreControllerObserver[T] interface (an Update(event EventType, params ...T) method). To broadcast an event to all controllers that observe that type, call core.NotifyObservers(...):

go
core.NotifyObservers[*User](EventUserCreated, user)

NotifyObservers iterates over the registered controllers and invokes Update(...) on any controller that implements the matching observer interface, keeping controllers decoupled while still able to react to one another's events.

Integration patterns

Infrastructure libraries (servers, databases, caches, brokers, clients) are installed into the Fountain instance through one of two patterns.

Config-key pattern — bind the component to a nested key in config.yaml and install it:

go
client := fedis.WithConfigKey("redis").InstallFountainInstance()

Functional-options pattern — configure the component inline with With* option functions:

go
server := fgrpc.WithConfigKey("fgrpc").
    InstallFountainInstance(fgrpc.WithServerOptions(serverOptions...))

The two patterns compose: a common form is to select the config key with WithConfigKey(...) and then pass functional options to InstallFountainInstance(...). Configuration lives in config.yaml with nested keys, and environment variables use the FOUNTAIN_ prefix.

Conventions

Fountain follows a consistent set of conventions across the codebase:

  • Package naming — utility/library packages use an f* prefix: flog, fedis, fhttp, fgrpc, and so on. Protocol client directories use f<protocol>_client/.
  • Constants — global constants use a K prefix in UPPER_SNAKE_CASE (for example KPackageName, KServerKindProvider).
  • Import ordering — group imports as: standard library → third-party → gitlab.soludian.com/soludian/fountain/... → local packages.
  • Logging — use flog, not the standard library log package. Obtain a logger with flog.NewFountainLoggerOnce().
  • Errors — use the helpers in libs/ferr/ for error wrapping and handling.