Atomika framework repository
Find a file
2026-03-17 18:15:10 +00:00
.forgejo/workflows fix: workflow runner to use setup/go 2026-03-17 19:12:32 +01:00
.cz.json release 1.5.0 -> 1.6.0 2026-03-17 18:15:10 +00:00
.gitignore chore: .gitignore update 2025-10-25 13:44:31 +02:00
atmk.go feat: move cli in a different project 2025-11-18 22:28:41 +01:00
CHANGELOG.md release 1.5.0 -> 1.6.0 2026-03-17 18:15:10 +00:00
db.go fix: validator testing 2026-01-27 21:29:44 +01:00
db_mysql.go fix: validator testing 2026-01-27 21:29:44 +01:00
db_mysql_test.go feat: move cli in a different project 2025-11-18 22:28:41 +01:00
db_postgres.go fix: validator testing 2026-01-27 21:29:44 +01:00
db_postgres_test.go feat: move cli in a different project 2025-11-18 22:28:41 +01:00
db_sqlite.go fix: validator testing 2026-01-27 21:29:44 +01:00
db_sqlite_test.go feat: move cli in a different project 2025-11-18 22:28:41 +01:00
db_test.go feat: move cli in a different project 2025-11-18 22:28:41 +01:00
errors.go feat: add errors.go for custome error implementation 2026-01-29 15:37:54 +00:00
go.mod feat: update to go1.26 2026-03-17 18:57:13 +01:00
go.sum feat: update to go1.26 2026-03-17 18:57:13 +01:00
http.go fix: ws config path and reserved topic rejection 2026-02-27 16:42:21 +01:00
http_ws.go fix: ws config path and reserved topic rejection 2026-02-27 16:42:21 +01:00
http_ws_test.go fix: ws config path and reserved topic rejection 2026-02-27 16:42:21 +01:00
log.go feat: add new NewRuntime to override runtime behaviour 2026-03-12 18:53:15 +01:00
log_test.go feat: add new NewRuntime to override runtime behaviour 2026-03-12 18:53:15 +01:00
README.md feat: add new NewRuntime to override runtime behaviour 2026-03-12 18:53:15 +01:00
runtime.go feat: add new NewRuntime to override runtime behaviour 2026-03-12 18:53:15 +01:00
runtime_test.go feat: add new NewRuntime to override runtime behaviour 2026-03-12 18:53:15 +01:00
validate.go feat: add errors.go for custome error implementation 2026-01-29 15:37:54 +00:00
validate_test.go feat: add errors.go for custome error implementation 2026-01-29 15:37:54 +00:00

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() string
  • Run(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:

  • .git
  • tmp
  • vendor
  • node_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 named vendor anywhere in the tree
  • storage/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.DBDriverPostgres
  • atomika.DBDriverMySQL
  • atomika.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 POST only. OPTIONS is handled for preflight.
  • Decode reads up to 1MB from request body.
  • Encode writes JSON and gzip-compresses if Accept-Encoding: gzip is present.
  • Default error handler returns:
    • HTTP 200 for GenericError / ValidationError
    • HTTP 500 for non-typed errors

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 WWW is set, / serves files from that directory.
  • If WWW is empty, / returns UI disabled.
  • Relative WWW paths are resolved from current working directory.
  • RPC endpoints still work under BasePath in parallel.

6) Interceptors

Use interceptors to wrap route handlers.

Scopes supported by Use(scope, ...):

  • * or empty: global (all routes)
  • service: all actions in that service
  • service/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.auth is reserved for authentication. Do not register it via RegisterTopic(...).
  • Dispatcher() returns nil unless SetupWebSocket(...) is called first.

When using generated service adapters, WebsocketService is a naming convention:

  • Interface named WebsocketService is treated as WS transport contract.

Supported patterns:

  1. 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
}
  1. 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)
  1. Important rule for embedding mode:
  • If WebsocketService embeds another service, explicit methods declared inside WebsocketService are ignored.
  • Topic metadata must be defined on the embedded target service methods.

WS auth wiring:

  • Methods with // topic: ws.auth are auto-wired as auth handler via SetWSAuthHandler(...).
  • If more than one ws.auth method is declared, only the first is used (others are ignored with a warning).

Authentication result contract:

  • return nil error 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.go files 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

  1. Keep generated def and business service.go as source of truth.
  2. Replace generated server.go adapter layer first.
  3. Replace main.go boot/wiring.
  4. Remove atomika.io/atomika/atomika from go.mod when no imports remain.