When a service starts but behaves differently than expected, first check the boundary involved: who owns the resource, when configuration takes effect, and which guarantees the selected backend provides. These mistakes can leave code that compiles but does not do what its caller assumes.
Resource Ownership
Late Cleanup
If composition acquires a resource and then fails while constructing a consumer, cleanup registered afterward is never reached. Register release as soon as you own the resource, before the next fallible operation.
Application cleanup becomes active immediately and runs in reverse registration order, even if startup never runs. A registered stop function must therefore tolerate an unstarted resource. Keep that ownership in one place using Lifecycle and Ownership, rather than duplicating lifecycle wiring in every feature.
Unchecked Connectivity
A constructed driver is not necessarily connected. For example, Redis cache’s
constructor creates its client without network I/O; Start checks connectivity.
Omitting startup moves that failure to the first operation instead of rejecting
startup.
Follow the selected driver’s setup, including its available startup, cleanup, and readiness methods. Do not assume every driver has all three: some are stateless, and some memory drivers need no startup. Register lifecycle work with either your existing host/container or Arkoren’s application, not both.
Broad Dependencies
Passing *application.Application into business services gives them host
control and runtime state they do not need. Passing a composer also permits
hidden lifecycle registration. Keep those objects at the composition boundary;
pass typed services, a logger, or an event bus explicitly.
An event subscription is also an owned resource. Register its unsubscribe function for release instead of leaving listeners attached across repeated composition or tests; Own Subscriptions shows the focused pattern.
Construction Policies
Override Order
Construction callbacks run in argument order on one draft. When a setting
replaces an earlier value, put shared defaults first and the local override
last. With an existing cache.Driver named driver and a shared
cache.BuilderFunc named defaults, import arkoren.dev/core/service/cache:
store := cache.New(driver, defaults, func(store *cache.Builder) {
store.Name("reports")
})
The final name is reports; reversing the callbacks lets defaults replace
it. This is construction-time configuration, not a runtime settings API.
Changing a retained draft cannot reconfigure the published facade. See
Configuration for reusable
policies and validation boundaries.
Missing Observations
Registering an event listener does not connect every facade to that bus.
Components that expose an Events setting publish only when given a bus; a
database listener on the application’s bus sees nothing if the database facade
uses no bus. Application lifecycle events are separate from facade events.
Likewise, a facade with no explicit telemetry follows telemetry.Default();
the initial process default records nothing. Setting a default does not start
its exporters. Check both the component’s construction and the selected
telemetry’s lifecycle before diagnosing a missing metric. Use
Observe Outcomes
for lifecycle reporting and Capture Setup
for diagnostic capture. A failed diagnostic-tool declaration does not
necessarily undo capture attachment.
HTTP Boundaries
Middleware Registration
Router middleware is applied when routes are registered. Calling Use after
registering a route through that router or one derived from it panics; existing
routes would otherwise silently miss the new policy. Add shared middleware
before routes, and choose a group or With for narrower policies as described
in Choose Scope.
Explicit Recovery
router.New does not install panic recovery. For a buffered handler, install
recovery before registration so an ordinary panic can become a 500 Problem
Details response. Import arkoren.dev/core/http/router; status below is your
existing router.Handler:
routes := router.New()
routes.Use(router.Recover())
routes.Get("/status", status)
Recovery protects only the middleware and handlers inside it. Keep request logging outside recovery so it observes the converted error; Order Policies explains the order. Once a response is committed, recovery cannot replace bytes already sent with a fresh error document.
Hidden Errors
Writing an error-shaped JSON body and returning nil tells outer middleware
that the handler succeeded. It also bypasses central Problem Details rendering
and its request-instance handling. Return the error instead of manually
writing its representation, using Error Handling.
Streams have a separate commitment boundary; follow
Streaming Responses rather than
trying to render a second response after streaming starts.
Optional Zero Values
Go-value validation treats scalar 0, "", and false as absent. Ordinary
range and format rules skip absent values, so an integer schema with only
Min(1) accepts zero. Add Required when zero means missing. If zero is a
real supplied value that must be checked, use a pointer schema so a non-nil
pointer is present and its pointee is validated.
Decoding alone does not apply these rules. Follow Validate Input to run the schema at the request boundary, and Validation for presence semantics. Exact encoded JSON validation has different presence rules from validation of Go zero values.
Data Assumptions
Dialect Selection
query.Query.Build() uses ANSI SQL with ? placeholders. Sending that SQL
directly to PostgreSQL does not select PostgreSQL’s $1 placeholders; MySQL
also has different identifier quoting. Pass published queries to the database
facade so it compiles for the configured driver’s dialect. If you need SQL
text, use BuildFor with the intended dialect, as shown in
Query Builder.
Stable Pagination
Cursor pagination needs a total order. Ordering only by a non-unique timestamp
can skip or repeat rows at page boundaries. For a reports table with a unique
id, import arkoren.dev/foundation/query and end the order with that key:
reports := query.Table("reports", func(statement *query.Builder) {
statement.OrderByDesc("created_at")
statement.OrderByDesc("id")
})
Rows sharing created_at now have a deterministic tie-breaker. Use
Pagination for cursor construction and
Batching and Streaming for traversing
larger result sets; ordering alone does not choose a traversal strategy.
Deployment Limits
Process-Local State
Memory drivers are useful for tests, development, and intentionally ephemeral state. Separate replicas do not share their contents, and a restart loses them. Before deploying state that must survive or be shared, select a suitable backend and verify its persistence configuration using Choosing a Driver.
Operator Exposure
InfoHandler, verbose readiness, and diagnostic tools can reveal internal
metadata. They do not become protected merely because they are mounted on a
second port. Add an actual network or authorization boundary before exposing
them. Mount Probes
distinguishes minimal probes from detailed reports.
Shutdown Budgets
If the platform kills a process before draining and cleanup finish, connections and in-flight work can be cut off. Keep the HTTP drain timeout inside the application budget and allow additional platform time for cleanup and telemetry finalization. Follow Shutdown Budgets instead of copying one timeout to every layer.
Shutdown does not automatically cancel request contexts already in progress.
A long poll or server-sent event handler must also observe
server.ShuttingDown(ctx) and return cooperatively; otherwise it can hold the
drain open until the timeout. Streaming Responses
shows that cancellation boundary.