Skip to content

Project Layout Idioms

TypeScript projects almost universally put source code in a src/ directory. Beyond that, the layout is up to you — NestJS has its own conventions, Next.js has its own, and a plain Express project might look completely different.

Go has a well-established community standard that is worth learning early. Most Go projects you encounter on GitHub follow the same skeletal structure:

flowchart TD
  ROOT["myapp/"] --> CMD["cmd/"]
  CMD --> CMDAPP["myapp/"]
  CMDAPP --> MAIN["main.go (entry point binary)"]
  ROOT --> INT["internal/"]
  INT --> HAND["handler/user.go"]
  INT --> SVC["service/user.go"]
  INT --> REPO["repository/user.go"]
  ROOT --> PKG["pkg/"]
  PKG --> LOG["logger/logger.go"]
  ROOT --> MOD["go.mod"]
  ROOT --> SUM["go.sum"]
Idiomatic Go project layout
TypeScript
// Typical TypeScript / Node project
src/
main.ts // entry point
modules/
users/
users.controller.ts
users.service.ts
users.repository.ts
shared/
logger.ts
utils.ts
package.json
tsconfig.json
Go
// Idiomatic Go project
cmd/
myapp/
main.go // entry point
internal/
handler/
user.go
service/
user.go
repository/
user.go
pkg/
logger/
logger.go
go.mod
go.sum

The cmd/ directory holds the entry points for your program(s). Each sub-directory name becomes a binary. If you have a web server and a background worker:

Terminal window
cmd/
server/
main.go # go build ./cmd/server → produces ./server binary
worker/
main.go # go build ./cmd/worker → produces ./worker binary

This is useful because a single Go module can produce multiple binaries. There is no equivalent convention in Node — you would typically use separate package.json workspaces or separate repositories.

The internal/ directory is enforced by the Go compiler itself. Code inside internal/ can only be imported by code in the parent tree of that internal/ directory.

Terminal window
# Given this layout:
myapp/
internal/
service/
user.go # package service
# This import is ALLOWED (same module):
import "myapp/internal/service" // from cmd/myapp/main.go
# This import is BLOCKED (different module):
import "myapp/internal/service" // from github.com/other/proj
# compiler error: use of internal package not allowed

This gives you a hard visibility boundary that TypeScript cannot enforce — even private class members are a convention enforced only by tsc, not the runtime.

pkg/ is for code you explicitly want to be importable by external consumers. Think of it as your public API surface — the opposite of internal/. Many teams skip pkg/ entirely and put shared utilities directly in named packages at the root.

Go package names are short, lowercase, singular nouns — no underscores, no camelCase.

Terminal window
# Good
package handler
package service
package repository
package logger
# Avoid
package userHandlers # camelCase
package user_service # underscores
package handlers # plural (minor, but common to see singular)

The package name is what callers write: handler.NewUser(...), service.CreateOrder(...). Keep it short so the fully-qualified name reads naturally.

There is no runnable snippet here — this is all filesystem and compiler behaviour. Try creating the layout above in your terminal with mkdir -p cmd/myapp internal/service && touch cmd/myapp/main.go internal/service/user.go go.mod.

What does the `cmd/` directory conventionally hold in a Go project?
Who enforces the `internal/` visibility restriction?
Which of these is the correct Go package name style?
Code in `internal/` can be imported by: