Skip to content
JavaGo
Part VI · Building Services

Project Layout & Ecosystem

How Go services are laid out on disk, which Java library maps to which Go tool, and when code generation (sqlc, easyjson) is worth it.

Section 23 of 31 4 min read

Go has no Maven standard directory layout, and it does not need one: the compiler only cares about packages (directories) and the internal/ rule. Start flat, and add structure when the code asks for it. The official guidance is short and worth reading: Organizing a Go module (opens in a new tab).

A typical service layout

a service with one binary and a few packages
messaging/
├── go.mod
├── go.sum
├── cmd/
│   └── messaging/
│       └── main.go          wiring: config, dependencies, servers
├── internal/                importable only inside this module
│   ├── message/             the domain: types + business rules
│   │   ├── message.go
│   │   ├── service.go
│   │   └── service_test.go  tests live beside the code
│   ├── postgres/            storage implementation
│   │   ├── queries.sql
│   │   ├── queries.sql.go   generated by sqlc: do not edit
│   │   └── store.go
│   └── httpapi/             HTTP handlers and middleware
│       ├── routes.go
│       └── messages.go
├── migrations/              SQL schema migrations
└── Dockerfile
  • cmd/<name>/main.go holds one directory per binary. A single-binary project can keep main.go at the root.
  • internal/ is the default home for everything. Only create importable packages outside it when other modules really should depend on them.
  • Package by domain, not by layer. internal/message beats internal/services + internal/models + internal/repositories; Go packages that mirror Spring layers tend to import each other in circles.
  • Skip pkg/. The popular "golang-standards/project-layout" repository is not an official standard, and the Go team does not recommend a pkg/ directory.

By domain: imports point inward

By layer: imports tend to circle back

Fig. 23.1Package by domain and the imports all point one way: main wires everything, the HTTP and storage packages depend on the domain, and the domain depends on none of them. Packages that mirror Spring layers tend to need each other, and one import back up the chain is a cycle Go refuses to compile.

Java tools and their Go counterparts

JavaGo
Spring Boot web / Spring MVCnet/http (Go 1.22 routing); chi, Echo or Gin if you want more
Spring DI containerConstructor functions wired in main(); wire or fx for huge graphs
Jacksonencoding/json (backed by the faster v2 engine since Go 1.27)
JDBC + HikariCPdatabase/sql (has a built-in pool) or pgx + pgxpool for Postgres
Hibernate / JPAGORM, ent or Bun; most teams prefer SQL-first tools
jOOQ / MyBatissqlc (SQL files → typed Go code) or Squirrel
Flyway / Liquibasegolang-migrate, goose or Atlas
JUnit 5 + AssertJtesting + testify (or plain if checks)
MockitoHand-written fakes behind small interfaces; gomock or mockery if needed
Testcontainerstestcontainers-go
SLF4J + Logbacklog/slog
Micrometer + ActuatorPrometheus client_golang or OpenTelemetry; net/http/pprof
Resilience4jgolang.org/x/time/rate, sony/gobreaker, cenkalti/backoff
Caffeineristretto, otter, or a map + mutex for small caches
java.util.UUIDThe uuid package (Go 1.27); github.com/google/uuid before that
LombokNothing: no getters or setters to generate
Checkstyle / SpotBugs / PMDgofmt, go vet, staticcheck, golangci-lint
GraalVM native-imageThe default: go build produces a static native binary

SQL-first data access with sqlc

Java — JPA entity + repository
@Entity
@Table(name = "messages")
class Message {
    @Id @GeneratedValue
    private Long id;

    @Column(nullable = false)
    private String topic;
}

interface MessageRepository
        extends JpaRepository<Message, Long> {
    // JPQL derived from the method name at startup
    List<Message> findByTopicOrderByCreatedAtDesc(String topic);
}
Go — write SQL, generate typed code
// queries.sql
// -- name: ListByTopic :many
// SELECT id, topic, payload, created_at
// FROM messages
// WHERE topic = $1
// ORDER BY created_at DESC;

// "go tool sqlc generate" writes queries.sql.go with:
func (q *Queries) ListByTopic(ctx context.Context, topic string) ([]Message, error)

// Usage: typed, no reflection, no runtime query building
msgs, err := q.ListByTopic(ctx, "orders")

When code generation for JSON is worth it

Start with encoding/json

encoding/json uses reflection, and in Go 1.27 it is backed by the new v2 implementation, which is much faster at decoding. Generators such as easyjson produce hand-written-style marshalling code for extra speed, at the cost of generated files to keep in sync. Adopt one only after profiling shows JSON is a real bottleneck on a hot path, and when the types are stable.

easyjson, if profiling says you need it
//go:generate go tool easyjson -all message.go

type Message struct {
    ID      int64  `json:"id"`
    Topic   string `json:"topic"`
    Payload []byte `json:"payload"`
}

// "go generate ./..." writes message_easyjson.go with
// MarshalJSON/UnmarshalJSON methods (and faster MarshalEasyJSON).
// For the full speed-up call the easyjson package directly:
data, err := easyjson.Marshal(msg)
↑ ↓ to navigateEnter to openEsc to close