A practical workflow with Go, PostgreSQL, sqlc, gqlgen, and architecture-aware prompting

How I Use AI to Build Production-Ready GraphQL Resources Faster

Software teams often need to add a new database resource across several layers of an application. The individual changes are usually straightforward, but the complete implementation can be repetitive and error-prone.

For one table, I may need to create:

  • a PostgreSQL schema
  • list and count queries
  • optional filters for every column
  • safe pagination and ordering
  • sqlc-generated Go code
  • GraphQL types, filters, enums, and connections
  • gqlgen-generated models
  • a service layer
  • database-to-GraphQL mappers
  • dependency-injection registration
  • a resolver
  • unit, contract, and integration tests

Instead of manually repeating this work, I use AI as an implementation partner. I provide a detailed architecture contract and a table definition, and the AI completes the vertical slice using the repository’s existing patterns.

The important point is that AI alone is not the architecture.

The speed comes from combining AI with senior software-engineering knowledge: clear boundaries, established conventions, safe SQL patterns, code generation, dependency injection, exhaustive tests, and reproducible validation.

How I Use AI to Build Production-Ready GraphQL Resources Faster

The Stack

The project in this example uses:

  • Go for the application and service layers
  • PostgreSQL as the database
  • sqlc for type-safe Go code generated from SQL
  • gqlgen for GraphQL schema and resolver generation
  • Uber Fx for dependency injection
  • GraphQL connection objects for lists and total counts
  • Go unit and integration tests for validation

The resource flow looks like this:

sql
PostgreSQL table
      ↓
SQL schema and sqlc queries
      ↓
Generated database models and parameter types
      ↓
GraphQL schema and generated models
      ↓
Filter and row mappers
      ↓
Service layer
      ↓
GraphQL resolver
      ↓
HTTP integration test

This architecture already existed before AI entered the process. My goal was to teach the AI how to extend it consistently.

Why a Short Prompt Is Not Enough

A request such as this is too vague:

“Add GraphQL support for this table.”

It leaves important architectural decisions undefined:

  • Should text filters use exact matching or ILIKE?
  • How should nullable database columns appear in GraphQL?
  • Is ordering implemented safely or through string interpolation?
  • Should list and count queries use identical filters?
  • How should camelCase GraphQL enum values map to snake_case SQL columns?
  • What happens when two records have the same selected sort value?
  • Are generated files edited directly or regenerated from their sources?
  • Which tests prove that the implementation is complete?

If these constraints are missing, AI must guess. Even when the generated code compiles, its design may not match the rest of the system.

I therefore use a large, reusable prompt that describes the complete engineering workflow.

My Reusable Ten-Step Prompt

The following is a simplified version of the master prompt I use. It is intentionally detailed because it acts as an architecture contract for the AI.

bash
Implement a complete sqlc and GraphQL resource for the PostgreSQL table
provided at the end of this prompt.
Inspect similar resources in the repository before making changes. Follow
the repository's existing naming, service, resolver, dependency-injection,
generation, and testing conventions. Preserve unrelated user changes.
Step 1: Add the sqlc schema and queries
- Create internal/storage/sql/sqlc/schema/<table>.sql.
- Create internal/storage/sql/sqlc/queries/<table>.sql.
- Add exactly two primary queries:
  1. List<EntityPlural> :many
  2. Count<EntityPlural> :one
- Both queries must contain identical optional filters.
- Use exact matching for identifiers, integers, and row_hash.
- Use case-insensitive substring matching with ILIKE for VARCHAR and TEXT.
- Use min_<field> and max_<field> for DOUBLE PRECISION fields.
- Use start_<field> and end_<field> for timestamps.
- Compare JSON values by casting both operands to jsonb.
- Support job_id as an exact-match filter.
- Support soft deletion with an only_deleted boolean.
- Use sqlc.narg for every optional argument.
- Add nullable limit and offset parameters to the list query.
- Support ordering by every physical column except job_id and extra_data.
- Implement ordering using static CASE expressions. Never interpolate SQL
  identifiers or directions.
- Include both ASC and DESC cases for every allowed order key.
- End ordering with id ASC as a deterministic tie-breaker.
Step 2: Generate sqlc code
- Run sqlc generate.
- Verify the database model, list/count parameter structs, and query methods.
- Confirm OrderBy and OrderDir are sql.NullString values.
- Do not edit sqlc-generated Go files manually.
Step 3: Add the GraphQL schema
- Create schema/<resource>.graphql.
- Define the entity, filter input, order-by enum, and connection type.
- Add the query field with filter and pagination arguments.
- Use Int64 for BIGINT and BIGSERIAL values.
- Match GraphQL nullability to database constraints.
- Convert database snake_case names to GraphQL camelCase names.
- Include every physical table column in the order enum except job_id and
  extra_data.
- Keep onlyDeleted in the filter, not on the entity.
Step 4: Generate gqlgen code
- Run gqlgen generate.
- Implement the new resolver method in the appropriate resolver file.
- Never manually edit gqlgen-generated files.
Step 5: Add the service
- Create internal/service/<service_package>/ following the closest resource.
- Inject *sqlc.Queries, *zap.Logger, *cache.Store, and *config.Config.
- Map GraphQL filters and pagination to sqlc list parameters.
- Execute the list query.
- Execute the count query with identical filters.
- Convert database rows into GraphQL models.
- Return the connection object containing records and total count.
- Log and return database errors consistently with existing services.
Step 6: Add the mapper generator
- Create cmd/gen/filter_mapper_<service_package>.go.
- Add a go:generate directive to the service.
- Generate the GraphQL-to-sqlc filter mapper.
- Generate the sqlc-to-GraphQL row mapper.
- Explicitly allowlist every GraphQL order enum value.
- Convert all multi word camelCase enum values to snake_case SQL keys.
- Return no valid SQL key for unsupported enum values.
- Fix the generator template, not only its generated output.
Step 7: Register the service and resolver
- Register the service constructor in fx.Provide.
- Add the service to the root resolver dependency struct.
- Add it to the resolver constructor parameters and assignments.
- Implement the GraphQL query resolver by delegating to the service.
Step 8: Handle naming carefully
- Respect gqlgen-generated Go field names and common initialisms.
- Keep table, query, GraphQL, package, service, connection, and resolver names
  consistent across all layers.
- Do not merge similarly named fields accidentally.
Step 9: Add tests
- Test representative string, numeric, timestamp, JSON, boolean, and
  soft-delete filters.
- Test pagination and total count.
- Test database-to-GraphQL conversion.
- Iterate over every generated order-by enum and verify its SQL key.
- Test every enum in both ASC and DESC directions.
- Test every multi word camelCase-to-snake_case conversion.
- Verify id ASC remains the deterministic tie-breaker.
- Verify job_id and extra_data are not available for ordering.
- Verify list and count SQL queries contain identical filters.
- Add an HTTP GraphQL integration test based on an existing resource test.
- The integration test must check total, list fields, filters, pagination,
  ordering, soft deletion, and empty result sets.
Step 10: Validate everything
Run:
  sqlc generate
  gqlgen generate
  go generate ./internal/service/<service_package>
  gofmt -w <changed-go-files>
  go test ./...
Also verify:
- git diff --check passes;
- generated files are reproducible and up to date;
- list and count filters are identical;
- every GraphQL order value maps to a supported SQL key;
- every SQL order key has ASC and DESC cases;
- job_id and extra_data do not appear in ordering cases;
- unrelated changes remain untouched.
Finally, summarize the files created, architecture implemented, generation
commands executed, test results, and any environmental limitations.
Input table:
<PASTE CREATE TABLE STATEMENT HERE>

This prompt does more than request code. It defines what “complete” means.

Example Input: Adding a Fictional Location Resource

For this article, I use a deliberately small, fictional table. The names and fields below demonstrate the workflow without exposing a production schema:

sql
CREATE TABLE example_locations (
  id BIGSERIAL PRIMARY KEY,
  display_name TEXT,
  category VARCHAR(50),
  is_active BOOLEAN,
  longitude DOUBLE PRECISION,
  latitude DOUBLE PRECISION,
  -- Metadata
  row_hash VARCHAR(32) NOT NULL UNIQUE,
  job_id INT NOT NULL,
  extra_data JSON,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
  deleted_at TIMESTAMP WITH TIME ZONE
);

From this input, the AI can determine the required filter types:

PostgreSQL Column Type GraphQL / SQL Behavior BIGSERIAL GraphQL Int64, exact-match filter TEXT / VARCHAR GraphQL String, SQL ILIKE filter BOOLEAN GraphQL Boolean, exact-match filter INTEGER GraphQL Int, exact-match filter DOUBLE PRECISION GraphQL Float, minimum and maximum filters JSON GraphQL JSON, normalized jsonb comparison TIMESTAMPTZ GraphQL Time, start and end filters deleted_at Time range plus onlyDeleted state filter

The AI also sees that only three columns are non-null at the database level: id, row_hash, and job_id. Therefore, these fields are non-null in GraphQL, while columns with database defaults but without NOT NULL remain nullable.

What the AI Generated

The prompt produced a complete vertical slice rather than a single resolver:

bash
internal/storage/sql/sqlc/schema/example_locations.sql
internal/storage/sql/sqlc/queries/example_locations.sql
internal/storage/sql/sqlc/example_locations.sql.go          # generated
schema/example_location.graphql
internal/graph/model/models_gen.go                          # generated
internal/graph/generated/generated.go                       # generated
internal/service/examplelocation/service.go
internal/service/examplelocation/example_location_filter_mapper.gen.go
cmd/gen/filter_mapper_examplelocation.go
internal/graph/resolvers/examplelocation.resolvers.go
internal/service/examplelocation/mapper_test.go
internal/service/examplelocation/query_contract_test.go
test/examplelocation_test.go

It also updated the application registration, root resolver dependencies, and GraphQL query schema.

The resulting GraphQL query looks like this:

php
query EXAMPLE_LOCATIONS {
  exampleLocation(
    filter: {
      onlyDeleted: false
      orderBy: displayName
      orderDir: ASC
      category: "PUBLIC_DEMO"
      minLatitude: -90
      maxLatitude: 90
    }
    pagination: {
      limit: 100
      offset: 0
    }
  ) {
    total
    exampleLocations {
      id
      displayName
      category
      isActive
      longitude
      latitude
      rowHash
      jobId
      extraData
      createdAt
      updatedAt
      deletedAt
    }
  }
}

A Subtle Bug That Architecture-Aware Prompting Prevents

GraphQL uses camelCase, while PostgreSQL usually uses snake_case:

typescript
displayName → display_name
rowHash     → row_hash
createdAt   → created_at
updatedAt   → updated_at
deletedAt   → deleted_at

A naive mapper might do this:

text
params.OrderBy = sql.NullString{
    String: string(*filter.OrderBy),
    Valid:  true,
}

That works for a single-word field such as category, but fails for deletedAt. The SQL ordering block expects deleted_at, so every ordering CASE evaluates to false. The query still runs, which makes this bug easy to miss.

The correct implementation uses an explicit allowlist:

go
func exampleLocationOrderBySQLName(orderBy model.ExampleLocationOrderBy) string {
    switch orderBy {
    case model.ExampleLocationOrderByDisplayName:
        return "display_name"
    case model.ExampleLocationOrderByRowHash:
        return "row_hash"
    case model.ExampleLocationOrderByCreatedAt:
        return "created_at"
    case model.ExampleLocationOrderByDeletedAt:
        return "deleted_at"
    // Every other allowed enum is also listed explicitly.
    default:
        return ""
    }
}

The test then iterates over every generated enum value and verifies that it has an expected SQL key. If a developer adds an enum later but forgets the mapper, the test fails immediately.

This is the difference between code that merely compiles and code that protects future changes.

AI Should Modify Generator Sources, Not Generated Files

Both sqlc and gqlgen produce generated code. The mapper in this workflow is generated too.

Editing generated files directly may create a quick local fix, but the next generation command removes it. My prompt therefore requires the AI to update the source of truth:

  • SQL schema and query files for sqlc output
  • GraphQL schema files for gqlgen output
  • mapper-generator templates for mapper output

After every change, generation is run again. This keeps the repository reproducible and prevents “works until regeneration” defects.

Tests Are Part of the Prompt, Not an Afterthought

The AI does not finish when the application compiles. The generated resource must prove several contracts.

Filter contract. The tests verify representative filters for strings, booleans, and integers, latitude and longitude ranges, timestamp ranges, JSON values, and soft-deleted vs. non-deleted records.

List/count consistency. Pagination belongs only to the list query, but all actual filters must be identical between list and count. Otherwise, a page may return five records while total describes a different data set. A contract test extracts and normalizes both SQL WHERE clauses and compares them.

Ordering contract. For every GraphQL order enum, tests verify the expected SQL key, the ASC direction, the DESC direction, camelCase-to-snake_case conversion, exclusion of job_id and extra_data, and the final id ASC deterministic tie-breaker.

Integration contract. The HTTP integration test starts the real test application, sends a GraphQL request, and verifies the root query field, connection and total count, returned entity fields, filter constraints, pagination limit, ordering, soft-delete behavior, and safe handling of an empty result set.

Why Senior Engineering Knowledge Still Matters

AI accelerates implementation, but it does not remove the need for engineering judgment. A senior engineer must still define:

  1. Boundaries — SQL, generated database code, GraphQL, service, resolver, and transport each have a clear responsibility.
  2. Sources of truth — schemas and generators own generated code.
  3. Safety rules — static SQL ordering avoids identifier injection.
  4. Data semantics — nullability, substring matching, JSON normalization, and soft deletion must be intentional.
  5. Consistency rules — list and count filters must describe the same data set.
  6. Evolution rules — exhaustive enum tests prevent silent failures after future additions.
  7. Operational validation — formatting, generation, tests, and diff checks are mandatory.

AI is extremely effective when these decisions are explicit. Without them, it can generate a lot of code quickly while also generating architectural inconsistency quickly.

My role shifts from typing repetitive code to designing the system, defining constraints, reviewing decisions, and validating the result.

The Validation Loop

I finish the workflow with a repeatable command sequence:

bash
sqlc generate
gqlgen generate
go generate ./internal/service/examplelocation
gofmt -w ./internal/service/examplelocation ./test/examplelocation_test.go
go test ./...
git diff --check

I also regenerate a second time and compare checksums when I want to confirm idempotence. If the output changes on every run, the generation process is not reproducible.

What Made This Workflow Fast

The main productivity improvement did not come from asking AI to write more code. It came from improving the quality of the instructions.

The reusable prompt contains repository context, architectural boundaries, precise naming rules, SQL safety requirements, generation ownership, known failure modes, exhaustive tests, and an explicit definition of completion.

Once this contract is stable, adding another table becomes mostly an input change. I provide a new CREATE TABLE statement, and AI applies the established architecture across the repository.

That is a much stronger use of AI than isolated code completion.

Final Thoughts

AI can help software teams build significantly faster, but sustainable speed requires architecture. My approach is simple:

  1. Design the architecture first.
  2. Encode its rules in a reusable prompt.
  3. Give AI a concrete table as input.
  4. Let AI implement the complete vertical slice.
  5. Require generators and tests to verify the result.
  6. Review the output as an engineer, not as a spectator.

The result is not “AI-generated code” in isolation. It is architecture-guided automation: AI handles repetitive implementation while engineering knowledge controls correctness, consistency, and maintainability.

That combination allows me to add features quickly without sacrificing the structure of the software.