Skip to content

Architecture ​

go-like is a composition layer for backend services. It has several small package contracts instead of one framework container. The application constructs the real dependencies, passes them into options or factories, and chooses where lifecycle ownership begins.

The useful question is not “which package has the most features?” It is “who owns this state, who admits it, who observes its terminal result, and which runtime or backend gives it meaning?”

The planes ​

text
Application composition root
  |
  +--> Context plane
  |      @go-like/context: cancel, deadline, cause, values
  |
  +--> Lifecycle plane
  |      @go-like/core: App, Server, hooks, registration, stop result
  |
  +--> External Web plane
  |      framework or application Handler: Request -> Response
  |      @go-like/web: Context bridge
  |      runtime host: listener, sockets, Web API conversion
  |
  +--> Internal call plane
  |      Client -> Discovery -> Filter -> Selector -> Transport -> Server
  |      one JSON Fetch exchange or one SSE server stream
  |
  +--> State and control plane
  |      Config snapshots, Registry reachability, Store records, Cache values
  |
  +--> Event and work plane
  |      Broker/Event, NATS, RabbitMQ, Croner, BullMQ
  |
  +--> Operations plane
         Health, Resilience, Pino, Winston, OpenTelemetry, Prometheus

The arrows are composition dependencies, not global service lookups. A package can be portable while a selected provider is runtime-specific. For example, the root @go-like/transport-http client path uses standard Fetch, while @go-like/transport-http/node adds a Node host and native HTTP/TLS behavior.

Ownership map ​

Boundarygo-like ownsThe application or provider still owns
@go-like/core AppStartup admission, hooks, registration calls, stop coordination, lifecycle resultConstruction, dependency graph, business policy, native resources not handed to a Server
Structural ServerOne start(ctx) / stop(ctx) contractThe actual resource and its terminal semantics
@go-like/webStandard Handler type and request Context bridgeURL routes, framework middleware, authentication, authorization, Web Streams, upgrades
@go-like/clientLogical call middleware, discovery watchers, logical Transport Client owners, explicit retry loopBusiness idempotency, provider connection reuse, credentials, deployment policy
@go-like/serverInternal POST /<service>/<endpoint> route table, JSON and SSE dispatch, route validation, operation middlewareExternal HTTP routes, business validation, transport listener implementation
@go-like/registrySnapshot, filter, selector, feedback contractsBackend leases, TTL, sessions, revisions, consistency, authentication
@go-like/configImmutable merged snapshots, accepted source watchers, last-good publicationSource credentials, backend watch model, application schema and rollout policy
@go-like/storeContext-first records, revisions, CAS options, pagesDurability, transaction scope, multi-process guarantees, backend limits
@go-like/cacheDisposable values and TTL optionCache invalidation policy, authority, persistence, cross-process semantics
@go-like/broker and @go-like/eventByte boundary, optional codec, accepted subscription stop, native delivery identityAck/nack/term, redelivery, durable consumers, DLQ, connection and stream
Observability adaptersExplicit wrapper and admitted shutdown boundaryLogger, exporter, provider, registry, labels, redaction, global setup

A resource may be borrowed at construction and transferred into a go-like lifecycle adapter only after successful admission. The adapter must document that transfer. A timeout while waiting for an owner does not automatically mean that the native object has stopped.

Listeners and Docker published ports ​

Local process examples that bind 127.0.0.1 and then curl that address are still the right getting-started shape. Compose and Docker Desktop are a different topology.

A published host port can accept TCP through docker-proxy before the container listener is serving HTTP or TLS. Redis, AMQP, etcd, and application HTTP that only work on the Compose network should be reached by service DNS on that network (often through a dest-owned proxy), not treated as a stable host localhost handshake. Server.start(ctx) still does not mean readiness. Endpointer.endpoint(ctx) proves an address that can be advertised or registered. afterStart proves only that Core reached that hook; it becomes an admission marker only when the hook itself waits for a resource-specific admission signal.

startRecoveringRabbitMqBroker admits a Broker handle before connector setup finishes so the application can bind HTTP without a bind-first workaround. Publish stays fail-closed until a generation exists. That is admission, not a health endpoint: @go-like/broker-rabbitmq does not serve /healthz.

Context scopes ​

Core creates three important scopes:

text
parent Context
      |
      v
appContext = withCancel(withValue(parent, AppInfo))
      |
      +--> startup hooks, endpoint preparation, registration, afterStart
      |
      +--> withoutCancel(appContext)
              |
              v
      serverContext = withCancel(...)
              |
              +--> long-lived Server.start(ctx)
              |
              +--> canceled during App.stop after deregistration

cleanupContext = withoutCancel(appContext)
      |
      +--> optional absolute stopTimeout
      +--> beforeStop, deregistration, Server.stop, terminal joins, afterStop

A caller's cancellation and an owner's lifecycle are different facts:

  • ctx.err() reports the Context's cancellation or deadline result.
  • cause(ctx) can expose a caller-supplied Error while err() remains the stable cancellation category.
  • waitForContext(ctx, operation) lets a caller stop waiting without canceling the underlying operation.
  • App.stop() has no caller Context parameter. It returns one stable, shared Promise for the application's idempotent stop operation.
  • A Server.stop(ctx) implementation may use its ctx to bound one caller's wait while continuing owner cleanup in a detached operation.

App lifecycle ​

The public Core contracts are structural:

ts
interface Server {
  start(ctx: Context): Promise<void>
  stop(ctx: Context): Promise<void>
}

interface App {
  run(): Promise<void>
  stop(): Promise<void>
}

The startup path is:

  1. Run beforeStart hooks sequentially in declaration order.
  2. Invoke each Server.start(serverContext) in declaration order. Invoking a start is not the same as waiting for readiness; a long-lived server may keep its Promise pending until termination.
  3. Resolve Endpointer.endpoint(startContext) values for registration, sequentially.
  4. Register one immutable ServiceInstance when registrar(...) is configured.
  5. Run afterStart hooks sequentially.
  6. Continue until App.stop() is requested or a launched Server reports a terminal failure.

Endpointer.endpoint(ctx) proves an address that can be advertised or registered. afterStart proves only that Core reached that hook; it becomes an admission marker only when the hook itself waits for a resource-specific admission signal.

The stop path is:

  1. Create a detached cleanup Context and, when configured, one absolute stopTimeout budget.
  2. Cancel the application Context so startup work and admission can stop.
  3. Join startup within the remaining stop budget.
  4. Run beforeStop hooks.
  5. Deregister the accepted ServiceInstance.
  6. Cancel the detached Server runtime Context.
  7. Invoke applicable Server.stop(stopContext) calls concurrently.
  8. Join all Server.start() terminal Promises.
  9. Run afterStop hooks and aggregate lifecycle failures.

Sibling Servers do not receive a general reverse-dependency shutdown guarantee. If a worker must stop before its queue or a client must close after a server, put that order inside one composed Server or an explicit hook. The server(...) option alone is not a dependency graph scheduler.

text
app.run()
  -> beforeStart hooks, sequential
  -> Server.start calls, declaration order, long-lived allowed
  -> endpoint preparation, if needed
  -> Registrar.register, if configured
  -> afterStart hooks, sequential
  -> running
  -> stop request or first Server terminal failure
  -> startup join under stop budget
  -> beforeStop hooks
  -> Registrar.deregister
  -> cancel Server runtime Context
  -> Server.stop calls, concurrent
  -> Server.start terminal joins
  -> afterStop hooks
  -> one success or aggregated failure

startTimeout(...) covers startup hooks, endpoint preparation, registration, and afterStart. It does not turn Server.start() into a readiness Promise. stopTimeout(...) is one shared caller-wait budget across cleanup phases. A late native resource may still settle after Core returns; record that as a cleanup failure or provider-specific terminal fact instead of reporting a clean close by assumption.

Request and dependency DAGs ​

The external Web path and internal service path deliberately do not share a handler type:

text
External Web
  Request
    -> framework route / application policy
    -> Handler(request)
    -> Response
    -> host writes response and owns sockets

Internal unary
  Context + Endpoint + typed input
    -> Client middleware
    -> direct address OR Discovery snapshot
    -> ordered Filters
    -> Selector.select
    -> Transport.dial or resident owner
    -> fetch(Request) at /<service>/<endpoint>
    -> Server route validation
    -> operation middleware
    -> typed decode, business handler, typed encode
    -> Response
    -> Client reads the body, ServiceError decode, typed validation
    -> SelectionDone feedback
    -> owner reuse or close

The internal operation identity is service/endpoint, for example appointment-policy.v1/check. A ServiceInstance.endpoints value is an opaque transport address, for example http://10.0.0.4:8080 or memory://orders. Do not use one in place of the other.

Runtime portability ​

“Portable” means that a shared entrypoint uses standard JavaScript and Web capabilities or explicitly injected capabilities. It does not mean every runtime can provide a TCP listener, process signals, file watching, UDP multicast, custom TLS, or a vendor client.

Entry point or capabilityBunNode.jsBoundary
@go-like/context, root @go-like/core, @go-like/health, @go-like/metadata, @go-like/resilience, @go-like/structsource/runtime lane in repositorysource/runtime lane in repositoryPortable package design; see the current verification lane before treating it as a release result
@go-like/web, @go-like/web/healthstandard Web handlerstandard Web handlerA Handler still needs a host
@go-like/transport-memoryprocess-localprocess-localSame Transport instance required; no cross-process behavior
@go-like/transport-http rootFetch client pathFetch client pathlisten needs an injected HTTPHost; root does not expose the Node host
@go-like/web/nodemay execute Node APIs in some environmentsNode hostThis is a Node-specific subpath, not a portable TLS host
@go-like/transport-http/nodeNode APIs; verify target runtimeNode HTTP/1.1, HTTP/2, TLS, mTLS, poolingNative Node listener and client behavior
@go-like/core/nodeBun/Node process signal APIsNode process signal APIsExplicit runtime-specific import
mDNS /node, file Store /node, ZooKeeper, NATS, Redis, RabbitMQ, BullMQprovider-dependentprovider-dependentRead the provider row, not only the root package name

The TypeScript DOM library setting supplies types. It is not a runtime polyfill and does not prove semantic parity. Runtime subpaths are explicit because package exports do not currently use conditional bun or node conditions.

Composition, not a container ​

A composition root should make ownership visible:

ts
import { newClient, withEndpoint, withTransport } from "@go-like/client"
import { afterStop, name, newApp, server } from "@go-like/core"
import { signal } from "@go-like/core/node"
import { address, newServer, transport as serverTransport } from "@go-like/server"
import { struct } from "@go-like/struct"
import { defineService } from "@go-like/transport"
import { newMemoryTransport } from "@go-like/transport-memory"
import { newNodeServer, port } from "@go-like/web/node"

const policy = defineService("policy", {
  check: {
    request: struct.object({ ok: struct.boolean() }),
    response: struct.object({ ok: struct.boolean() })
  }
})

const transport = newMemoryTransport()
const policyServer = newServer(serverTransport(transport), address("memory://policy"))
policy.registerHandler(policyServer, {
  check() {
    return { ok: true }
  }
})
const policyClient = newClient(withTransport(transport), withEndpoint("memory://policy"))
const webServer = newNodeServer((_request: Request) => new Response("ok"), port(3000))

const app = newApp(
  name("appointments"),
  server(policyServer, webServer),
  afterStop(async (ctx) => {
    await policyClient.close(ctx)
  }),
  signal()
)
void app

The example is a composition pattern. It does not make newApp a DI container, and it does not make a memory transport distributed. The application still chooses credentials, provider factories, authentication, data ownership, and shutdown ordering.

Historical API warning ​

Current documentation follows the source and public API tests at the 0.0.1 baseline. Older ADRs and design notes mention superseded names such as ServerHandle, AppHandle, automatic registration designs, and other lifecycle shapes. Treat those documents as historical; do not copy their signatures into new code.