SQL first
SQL-first data access starts from the idea that important queries deserve to be written as SQL, reviewed as SQL, and owned as part of the application contract. Tools can still generate code around those queries, but the query remains visible.
This is different from hiding all persistence behind an ORM expression language. ORMs are productive, but they can make complex SQL appear indirectly through method chains, eager-loading choices, and association rules.
sqlc and iBATIS/MyBatis come from different ecosystems, but they share a respect for SQL as authored code.
sqlc
sqlc reads SQL files and generates type-safe Go code for executing those queries. The developer writes SQL. The tool checks it against a schema and produces methods and structs.
The appeal is a small gap between what is reviewed and what runs. A query can use database-specific features, joins, returning clauses, and hand-tuned shapes without pretending they are generic object operations.
The trade-off is that the generated interface follows the SQL. If the domain needs richer behavior, that behavior still belongs in application code around the generated access layer.
iBATIS and MyBatis
iBATIS, later MyBatis, represents an older mapper tradition. SQL statements live in mapping files or annotations, and the framework maps parameters and results between objects and rows.
This style gives teams control over SQL while avoiding repetitive boilerplate for binding and mapping. It also makes the SQL inventory explicit, which can be helpful in large applications with performance-sensitive queries.
The downside is that mapping files can become their own layer of indirection. SQL is visible, but behavior may be split across XML, interfaces, and domain code.
The domain boundary
SQL-first access does not remove the need for domain modeling. It only clarifies who owns the query. The application still needs to decide where invariants live, how transactions are grouped, and which operations are allowed.
A generated query method that updates a balance is not a business operation by itself. The operation may require validation, authorization, idempotency, event publication, or locks. Those rules should not disappear into a data-access helper.
The clean shape is SQL for persistence precision, domain code for business meaning.
When it fits
SQL-first tools fit well when queries are central, performance matters, and the team is comfortable reviewing SQL. Reporting, search, bulk operations, and data-heavy services often benefit.
They are less attractive when the application is mostly simple CRUD and the ORM already expresses the workload directly. Handwritten SQL everywhere can create unnecessary ceremony.
The decision should follow query complexity. When the SQL matters enough to tune, it often matters enough to write directly.
Maintenance cost
The maintenance cost of SQL-first access is schema drift. When a column changes, generated code and mapping files must change with it. That is a feature when it fails loudly, and a problem when query ownership is unclear.
Keep SQL files near the feature or module that owns them, and make generated code boring. The more custom behavior accumulates around generated accessors, the less the team benefits from the clear SQL boundary.
A SQL-first workflow
The sqlc style starts by making the query the artifact under review. The SQL file is not a string hidden inside application code. It is the contract.
-- name: FindAccountByEmail :one
SELECT id, email, created_at
FROM accounts
WHERE lower(email) = lower($1)
LIMIT 1;
-- name: ListRecentInvoices :many
SELECT id, account_id, total_cents, issued_at
FROM invoices
WHERE account_id = $1
ORDER BY issued_at DESC
LIMIT $2;
From there, generated methods give the host language typed entry points. The application still decides transaction boundaries and domain behavior, but the query text stays close to the database concept it expresses.
account, err := queries.FindAccountByEmail(ctx, email)
if err != nil {
return err
}
invoices, err := queries.ListRecentInvoices(ctx, db.ListRecentInvoicesParams{
AccountID: account.ID,
Limit: 20,
})
The iBATIS comparison is useful because it points at the same instinct: keep SQL visible when SQL is the clearest language for the job. The tradeoff is that generated query APIs are less magical than an ORM, but they also leave less ambiguity about what will hit the database.
Where SQL-first hurts
SQL-first data access hurts when the application wants rich object graph behavior more than explicit queries. If every screen needs small variations of a nested domain object, generated query methods can multiply quickly. The code remains explicit, but the catalog of queries can become its own maintenance surface.
That is not an argument against sqlc or iBATIS-style design. It is a boundary. Use SQL-first access where query shape matters, where reviewability matters, and where the database is doing meaningful work. Use a higher-level mapper when the dominant task is ordinary object persistence and the generated query layer would only restate boilerplate.
The team also needs discipline around naming. Generated methods become part of the application vocabulary. If query names describe tables instead of use cases, the code can become explicit but still hard to navigate.
Good query names sound like application actions or read models, not like generic table accessors.
Sources
- sqlc documentation. https://docs.sqlc.dev/
- MyBatis documentation. https://mybatis.org/mybatis-3/
- Go documentation, database/sql. https://pkg.go.dev/database/sql