- Go 100%
| .forgejo/workflows | ||
| .cz.json | ||
| .gitignore | ||
| atmk.go | ||
| CHANGELOG.md | ||
| db.go | ||
| db_mysql.go | ||
| db_mysql_test.go | ||
| db_postgres.go | ||
| db_postgres_test.go | ||
| db_sqlite.go | ||
| db_sqlite_test.go | ||
| db_test.go | ||
| errors.go | ||
| go.mod | ||
| go.sum | ||
| http.go | ||
| http_ws.go | ||
| http_ws_test.go | ||
| log.go | ||
| log_test.go | ||
| README.md | ||
| runtime.go | ||
| runtime_test.go | ||
| validate.go | ||
| validate_test.go | ||
atomika
Atomika is a Go library for building service-based apps with:
- service lifecycle bootstrapping
- runtime config loading with env override
- HTTP RPC transport (with optional WebSocket support)
- DB connection/migration manager
- structured logging and payload validation
Quick Start
1) Install
go get atomika.io/atomika/atomika@latest
2) Create a minimal app
package main
import (
"log"
"net/http"
"atomika.io/atomika/atomika"
)
func main() {
httpSvc, err := atomika.NewHTTPService(&atomika.CfgHttp{
Port: "8080",
BasePath: "/rpc/",
})
if err != nil {
log.Fatal(err)
}
httpSvc.Register(atomika.Route{
ServiceName: "health",
ActionName: "check",
Handler: func(w http.ResponseWriter, r *http.Request) {
_ = atomika.Encode(w, r, http.StatusOK, map[string]any{"ok": true})
},
})
app := atomika.New()
app.RegisterServices([]atomika.Service{httpSvc})
if err = app.Boot(); err != nil {
log.Fatal(err)
}
}
3) Call it
curl -X POST http://localhost:8080/rpc/health/check
How To Use
1) App Boot (service lifecycle)
Register services implementing:
ID() stringRun(ctx context.Context) error
app := atomika.New()
app.RegisterServices([]atomika.Service{svcA, svcB})
err := app.Boot() // blocks until shutdown or service error
Atomika-compatible custom service example:
package main
import (
"context"
"log"
"time"
"atomika.io/atomika/atomika"
)
type WorkerService struct {
name string
}
func NewWorkerService(name string) *WorkerService {
return &WorkerService{name: name}
}
func (s *WorkerService) ID() string {
return s.name
}
func (s *WorkerService) Run(ctx context.Context) error {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
// periodic work
}
}
}
func main() {
svcA := NewWorkerService("worker-a")
svcB := NewWorkerService("worker-b")
app := atomika.New()
app.RegisterServices([]atomika.Service{svcA, svcB})
if err := app.Boot(); err != nil {
log.Fatal(err)
}
}
2) Runtime Config (JSON + env overrides)
Initialize runtime config explicitly before calling ResolveConf(...) or ResolveConfT[...].
With no options, NewRuntime() loads all .json files from the working directory tree and ignores:
.gittmpvendornode_modules
type AppCfg struct {
Name string `mapstructure:"name" atmkenv:"APP_NAME" default:"demo"`
}
err := atomika.NewRuntime()
if err != nil {
return
}
cfg, err := atomika.ResolveConfT[AppCfg]("app")
if err != nil {
return
}
To override the ignore list, use WithIgnoreFolders([]string). This is a full override, so include every directory you want ignored.
err := atomika.NewRuntime(atomika.WithIgnoreFolders([]string{
".git",
"tmp",
"vendor",
"node_modules",
"storage/volumes",
}))
if err != nil {
return
}
Ignore matching supports both forms:
vendor: ignores any directory namedvendoranywhere in the treestorage/volumes: ignores only that specific relative path
Sample config.json:
{
"app": {
"name": "my-service"
}
}
Environment variables override JSON/default values when struct fields use atmkenv tags.
3) Database Manager
Use CfgDatabase + DBManager to connect and run migrations.
ctx := context.Background()
cfg := &atomika.CfgDatabase{
Driver: atomika.DBDriverSQLite,
Name: "app.db",
AutoMigrate: true,
MigrationsPath: "./migrations",
}
mgr, err := atomika.NewDBManager(cfg)
if err != nil {
return
}
db, err := mgr.Connect(ctx)
if err != nil {
return
}
_ = db
defer mgr.Close(ctx)
Supported drivers:
atomika.DBDriverPostgresatomika.DBDriverMySQLatomika.DBDriverSQLite
4) HTTP RPC Routes
Routes are mounted as:
<BasePath>/<ServiceName>/<ActionName>
type CreateUserReq struct {
Email string `json:"email" validate:"required,email"`
}
httpSvc.Register(atomika.Route{
ServiceName: "users",
ActionName: "create",
Handler: func(w http.ResponseWriter, r *http.Request) {
var req CreateUserReq
if err := atomika.Decode(r.Body, &req); err != nil {
httpSvc.OnError(w, r, err)
return
}
if err := atomika.ValidateStruct(req); err != nil {
httpSvc.OnError(w, r, err)
return
}
_ = atomika.Encode(w, r, http.StatusOK, map[string]any{"created": true})
},
})
Details:
- RPC routes accept
POSTonly.OPTIONSis handled for preflight. Decodereads up to 1MB from request body.Encodewrites JSON and gzip-compresses ifAccept-Encoding: gzipis present.- Default error handler returns:
- HTTP
200forGenericError/ValidationError - HTTP
500for non-typed errors
- HTTP
CORS configuration example:
httpSvc, _ := atomika.NewHTTPService(&atomika.CfgHttp{
Port: "8080",
BasePath: "/rpc/",
AllowedOrigins: []string{"https://app.example.com"},
AllowedMethods: []string{"POST", "OPTIONS"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
AllowCredentials: true,
})
5) UI Hosting (static assets)
Use CfgHttp.WWW to serve static files from /.
httpSvc, _ := atomika.NewHTTPService(&atomika.CfgHttp{
Port: "8080",
BasePath: "/rpc/",
WWW: "./web/dist",
})
Details:
- If
WWWis set,/serves files from that directory. - If
WWWis empty,/returnsUI disabled. - Relative
WWWpaths are resolved from current working directory. - RPC endpoints still work under
BasePathin parallel.
6) Interceptors
Use interceptors to wrap route handlers.
Scopes supported by Use(scope, ...):
*or empty: global (all routes)service: all actions in that serviceservice/action: one route only
type AuthInterceptor struct{}
func (a AuthInterceptor) Intercept(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
httpSvc.OnError(w, r, atomika.NewGenericError(atomika.CodeUnauthenticated, "missing token"))
return
}
next(w, r)
}
}
httpSvc.Use("*", AuthInterceptor{}) // global
httpSvc.Use("users", AuthInterceptor{}) // all users/* routes
httpSvc.Use("users/create", AuthInterceptor{}) // only users/create
7) WebSocket (optional)
Enable WebSocket endpoint and register topics:
httpSvc.SetupWebSocket(&atomika.WsConfig{Path: "/ws"})
httpSvc.RegisterTopic(atomika.WSTopic{
Name: "chat.message",
Handler: func(ctx context.Context, connID string, payload []byte) {
// handle payload
},
})
d := httpSvc.Dispatcher()
if d != nil {
d.Broadcast(context.Background(), []byte(`{"event":"server.started"}`))
}
Details:
- WebSocket handler expects envelopes in this shape:
{"topic":"chat.message","payload":{"text":"hello"}}
- Reserved internal topics:
ws.auth,ws.keepalive,ws.ping. ws.authis reserved for authentication. Do not register it viaRegisterTopic(...).Dispatcher()returnsnilunlessSetupWebSocket(...)is called first.
When using generated service adapters, WebsocketService is a naming convention:
- Interface named
WebsocketServiceis treated as WS transport contract.
Supported patterns:
- Separate HTTP and WS contracts (default):
type EchoService interface {
Echo(EchoReq) EchoRes // HTTP
}
type WebsocketService interface {
// topic: ws.auth
Auth(AuthReq) AuthRes
// topic: echoV2
EchoV2(EchoReq) EchoRes
}
- Hybrid contract via embedding (same methods for HTTP + WS):
type EchoService interface {
// topic: ws.auth
Auth(AuthReq) AuthRes
// topic: echo
Echo(EchoReq) EchoRes
}
type WebsocketService interface {
EchoService
}
This generates WS registration that accepts EchoService directly:
func RegisterWebsocketServiceServer(server *atmk.HTTPService, echoService EchoService)
- Important rule for embedding mode:
- If
WebsocketServiceembeds another service, explicit methods declared insideWebsocketServiceare ignored. - Topic metadata must be defined on the embedded target service methods.
WS auth wiring:
- Methods with
// topic: ws.authare auto-wired as auth handler viaSetWSAuthHandler(...). - If more than one
ws.authmethod is declared, only the first is used (others are ignored with a warning).
Authentication result contract:
- return
nilerror from service call: connection is marked authenticated - return non-nil error: authentication fails and connection is rejected/closed
Example (ws.auth):
func (s *EchoServiceService) Auth(ctx context.Context, req EchoReq) (*EchoRes, error) {
if req.Token == "" {
return nil, errors.New("missing token")
}
if req.Token != "expected-token" {
return nil, errors.New("invalid token")
}
// nil error => websocket client is authenticated
return &EchoRes{Message: &Message{Body: "ok"}}, nil
}
8) Logging
logger, cleanup, err := atomika.NewLog(&atomika.CfgLog{
Format: atomika.FormatJSON,
Level: atomika.LevelInfo,
Output: "stdout",
})
if err != nil {
return
}
defer cleanup()
logger.Info("service started")
Output supports: stdout, stderr, file://..., tcp://..., udp://....
No Vendor Lock-In (Generated Projects)
Atomika is intended to be used by generated code, but it does not force hard lock-in.
In generated projects, the dependency is mostly concentrated in:
- bootstrap/wiring (
main.go) - transport adapters (
server.gofiles that register HTTP/WS handlers) - helper calls (
Decode,Encode, logging)
Your core business contract remains plain Go interfaces and types (context.Context, structs, errors), so migration is straightforward.
What is easy to keep:
- service interfaces and request/response objects
- service implementation logic (
service.go) - DB/business code not tied to transport helpers
What you replace if you leave Atomika:
- runtime boot (
atmk.New().Boot()) - route/topic registration helpers (
Register...Server) - optional helper calls (
atmk.Decode,atmk.Encode,atmk.ValidateStruct,atmk.Log)
Example: Replace Atomika Boot With Standard net/http
Before:
httpService, _ := atmk.NewHTTPService(&atmk.CfgHttp{Port: "8080", BasePath: "/rpc/"})
myservice.RegisterMyServiceServer(httpService, svc)
app := atmk.New()
app.RegisterServices([]atmk.Service{httpService})
_ = app.Boot()
After:
mux := http.NewServeMux()
mux.HandleFunc("/rpc/MyService/Create", func(w http.ResponseWriter, r *http.Request) {
var req myservice.CreateReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
res, err := svc.Create(r.Context(), req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(res)
})
_ = http.ListenAndServe(":8080", mux)
Example: Keep Service Interface, Swap Framework
Your service contract stays unchanged:
type MyService interface {
Create(ctx context.Context, req CreateReq) (*CreateRes, error)
}
Only transport glue changes. You can move to:
- raw
net/http chi,gin,echo- gRPC
- any custom event/WebSocket layer
Example: WebSocket Handler Migration
Before (Atomika topic registration):
httpSvc.SetupWebSocket(&atomika.WsConfig{Path: "/ws"})
httpSvc.RegisterTopic(atomika.WSTopic{
Name: "chat.message",
Handler: func(ctx context.Context, connID string, payload []byte) {
_ = chatService.HandleMessage(ctx, connID, payload)
},
})
After (gorilla/websocket example, no Atomika transport):
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for {
_, msg, err := conn.ReadMessage()
if err != nil {
return
}
var env struct {
Topic string `json:"topic"`
Payload json.RawMessage `json:"payload"`
}
if err = json.Unmarshal(msg, &env); err != nil {
continue
}
if env.Topic == "chat.message" {
_ = chatService.HandleMessage(r.Context(), "manual-conn-id", env.Payload)
}
}
})
Your domain handler stays reusable; only connection lifecycle and routing glue changes.
Example: Database Migration
Before (Atomika DB manager + auto-migrate):
cfg := &atomika.CfgDatabase{
Driver: atomika.DBDriverPostgres,
Host: "localhost",
Port: "5432",
User: "app",
Pass: "secret",
Name: "appdb",
AutoMigrate: true,
MigrationsPath: "./migrations",
}
mgr, _ := atomika.NewDBManager(cfg)
db, _ := mgr.Connect(context.Background())
defer mgr.Close(context.Background())
_ = db
After (plain database/sql + migrate library directly):
dsn := "postgres://app:secret@localhost:5432/appdb?sslmode=disable"
db, err := sql.Open("pgx", dsn)
if err != nil {
return
}
defer db.Close()
m, err := migrate.New("file://"+absMigrationsPath, dsn)
if err == nil {
_ = m.Up()
}
Your repository/query code can stay the same if it already depends on *sql.DB.
Practical Exit Strategy
- Keep generated
defand businessservice.goas source of truth. - Replace generated
server.goadapter layer first. - Replace
main.goboot/wiring. - Remove
atomika.io/atomika/atomikafromgo.modwhen no imports remain.