Back to Notes

Quick update on Ticker-Rush


Update on Ticker-Rush

Over the past few months, I've hit several key milestones on the project:

Refined REST Endpoints

My main goal here was to clean up the routing: eliminate verbs from endpoint paths, adopt plural nouns, and structure resources hierarchically. Instead of embedding actions directly in the URL, HTTP methods now express the intent (e.g. using POST /api/v1/sessions for logging in and DELETE /api/v1/sessions for logging out).

OpenAPI (Swagger) Generation from Protobuf

This goes hand-in-hand with the API redesign. Visualizing the endpoints in Swagger makes it super obvious if you've missed a detail or deviated from consistent patterns.

Protobuf has a really neat ecosystem that lets us embed HTTP mappings directly into RPC service definitions using protoc-gen-openapiv2. This keeps the API contract as the single source of truth and generates clean Swagger docs automatically:

protobuf
// UserService manages account registration, authentication, logout, and profile updates.
service UserService {
  ...
  // Login authenticates a user and establishes an HTTP session (via Cookie).
  rpc Login(LoginRequest) returns (LoginResponse) {
    option (google.api.http) = {
      post: "/api/v1/sessions"
      body: "*"
    };
  }

  // Logout invalidates the active HTTP session cookie.
  rpc Logout(LogoutRequest) returns (LogoutResponse) {
    option (google.api.http) = {delete: "/api/v1/sessions"};
    option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
      security: {
        security_requirement: {
          key: "CookieAuth"
          value: {}
        }
      }
    };
  }
  ...
}

One important lesson here: be really deliberate when designing your Protobuf messages. Always keep the target audience in mind so you don't accidentally leak internal structures in public responses (e.g., separating messages for public vs. protected/internal endpoints).

Setting up Rate Limitter

After sorting out the API design, the next logical step was making sure the system doesn't fall over under heavy load. A rate limiter was a must-have.

I decided to implement a Fixed Window Counter rate limiting algorithm using Valkey. It tracks requests per time window and falls back to IP-based tracking if there's no authenticated user ID:

go
userID, ok := GetUserID(c)
var idKey string
if ok {
  idKey = fmt.Sprintf("user:%d", userID)
} else {
  idKey = fmt.Sprintf("ip:%s", c.ClientIP())
}

timeBucket := time.Now().Unix() / int64(rl.window.Seconds())

rlKey := fmt.Sprintf("rate_limit:%s:%d", idKey, timeBucket)

To ensure atomicity I offloaded the logic to a Redis Lua script. This guarantees the entire operation runs in a single RTT and EXPIRE is only executed when the counter is first initialized:

go
var rateLimitLuaScript = redis.NewScript(`
	local current = redis.call('INCR', KEYS[1])
	if current == 1 then
		redis.call('EXPIRE', KEYS[1], ARGV[1])
	end
	return current
`)

// Increment increases the count for a key and sets an expiration time.
func (rl *RateLimitter) Increment(ctx context.Context, key string, window time.Duration) (int64, error) {
	val, err := rateLimitLuaScript.Run(ctx, rl.valkey, []string{key}, int(window.Seconds())).Int64()
	if err != nil {
		return 0, err
	}

	return val, nil
}

To keep clients in the loop, we return standard rate-limiting headers:

go
c.Header("X-RateLimit-Limit", fmt.Sprintf("%d", rl.limit))
c.Header("X-RateLimit-Remaining", fmt.Sprintf("%d", rem))
c.Header("X-RateLimit-Reset", fmt.Sprintf("%d", resetTime))

...
c.Header("Retry-After", fmt.Sprintf("%d", retryAfter))

Automated Docker Deployment Workflow

Another feature I wanted to get into my CI/CD process was a clean Docker deployment workflow.

I set up a dedicated publisher user on the target VM with restricted Docker group permissions. The deployment workflow is fully automated via GitHub Actions: when I push a new release tag, the workflow logs into the VM via SSH, pulls the newly built image from GHCR, runs database migrations, and restarts the services. This only triggers when a new tag is created, keeping it nice and predictable.

Being Go-idiomatic

Beyond all the features, one of my main focus areas was keeping the architecture clean and idiomatic.

Since I'm mostly following DDD (Domain-Driven Design), I structure the project with clear boundaries for each domain. I maintain a strict separation between data layers:

  • DTOs / API Contracts: Auto-generated via .proto definitions.
  • Domain Models: Pure business logic, decoupled from transport and storage.
  • Persistence Models: Auto-generated via sqlc for PostgreSQL, plus hand-written structs for our Valkey/NoSQL accessors.

I also focused heavily on keeping the Go codebase idiomatic, rather than bringing habits from other ecosystems:

  • Consumer-defined interfaces: Declaring interfaces where they are actually consumed. This keeps packages decoupled and makes mocking for tests super easy.
  • Concise Naming: Avoiding redundant Java-style names. By leveraging Go's package structure, structs stay short and clean (e.g., user.Service and user.Repository instead of UserService and UserRepository).