Appearance
Schema Guide
This guide covers everything you need to know about writing DDK schemas, including directives, constraints, relationships, and best practices.
Table of Contents
- Basic Schema Structure
- Type Directive: @required
- Constraint Directive: @constraint
- Mapping Directive: @mapping
- Field Types
- Enums
- Custom Scalars
- Relationships
- Views and Materialized Views
- Schema Validation Rules
- Auto-Generated Resolvers
- Best Practices
Basic Schema Structure
DDK schemas use GraphQL Schema Definition Language (SDL) with custom directives for database mapping. The directives are defined in internal.graphqls which is automatically included during validation and generation.
Minimal Type Definition
Every DDK type needs the @required directive:
graphql
type User @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
name: String!
email: String!
}Key Elements:
@requireddirective controls CRUD generation and table creation- Primary key is mandatory for database-backed types
!indicates non-nullable fields- Field names become database column names directly (column name matches field name via
gorm:"column:{fieldName};")
Schema File Organization
DDK schemas are organized into two types of files:
{name}.orm.graphqls— ORM-backed types with auto-generated CRUD operations{name}.custom.graphqls— Custom operations marked with@resolver(type: "CUSTOM")
During regeneration, the DDK reads all .graphqls files (excluding *.custom.graphqls) to generate default CRUD resolvers, then processes all files together through gqlgen.
Type Directive: @required
The @required directive defines CRUD operations and table creation for a type.
Syntax
graphql
@required(type: "OPERATIONS", table: "true|false")Parameters
type (string)
Comma-separated list of operations to generate. The DDK's default_resolver_builder.go parses these flags and generates corresponding Query/Mutation fields:
CREATE— Generatescreate{Type}({allFields}): {Type}mutationREAD— Generatesget{Type}(id: ID!): {Type}query andget{Type}List(limit, offset, sortBy, sortOrder, ...filters): [{Type}]queryUPDATE— Generatesupdate{Type}(id: ID!, {allFields}): {Type}mutationDELETE— Generatesdelete{Type}(id: ID!): Booleanmutation
Examples:
graphql
# Full CRUD
type User @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
name: String!
}
# Read-only
type Category @required(type: "READ", table: "true") {
id: ID! @constraint(type: "primarykey")
name: String!
}
# No auto-generated operations (custom resolvers only)
type SearchResult @required(type: "", table: "false") {
id: ID!
score: Float!
}table (string)
Whether to create a database table. This is the key flag checked by the DDDPlugin — when table: "true", the plugin generates:
- An application repository interface (
application/repository/{type}.application.repository.go) - An infrastructure repository implementation (
infrastructure/repository/{type}.infrastructure.repository.go) - A field in
RepositoryHolderfor dependency injection - Mock repository implementations via mockgen
When table: "false", the type is added to an ignoreModels map and excluded from migration and DDD generation.
When to use table: "false":
graphql
# Type without database persistence (DTO)
type Statistics @required(type: "", table: "false") {
totalUsers: Int!
activeUsers: Int!
averageAge: Float!
}
# Type assembled from multiple sources
type UserProfile @required(type: "", table: "false") {
user: User!
preferences: JSON!
activityScore: Float!
}link (string, optional)
Links a non-table type to a model implementation:
graphql
type UserDTO @required(type: "READ", table: "false", link: "User") {
id: ID!
name: String!
}This creates a mapping tracked in the generator's dtoImplementations map.
view and viewType (string, optional)
Creates database views instead of tables. See Views and Materialized Views.
Constraint Directive: @constraint
The @constraint directive adds database constraints to fields. The model generator (hooks/model_generator.go) processes these into GORM struct tags.
Supported Constraint Types
The generator maps each constraint type to a GORM tag:
| Constraint Type | GORM Tag | Description |
|---|---|---|
primarykey | gorm:"primaryKey;" | Primary key field |
unique | gorm:"uniqueIndex;" | Unique index |
index | gorm:"index;" | Database index |
check | gorm:"check:{value};" | Check constraint with SQL expression |
default | gorm:"default:{value};" | Default value |
notnull | gorm:"not null;" | NOT NULL constraint |
json | gorm:"serializer:json;" | JSON serialization for complex types |
ignore | gorm:"-;" | Field ignored by GORM |
excludeAll | gorm:"-:all;" | Field excluded from all GORM operations |
create-read | gorm:"<-:create;" | Only writable on create |
update-read | gorm:"<-:update;" | Only writable on update |
create-update | gorm:"<-;" | Writable on create and update |
create-only | gorm:"<-:false;" | Only writable on create, no reads |
read-only | gorm:">:false;<:create;" | Read-only field |
updatetime-nano | gorm:"autoUpdateTime:nano;" | Auto-update time in nanoseconds |
updatetime-milli | gorm:"autoUpdateTime:milli;" | Auto-update time in milliseconds |
embedded | gorm:"embedded" | Embedded struct in GORM |
size | gorm:"type:varchar({value});" | Fixed-size varchar |
numeric | gorm:"type:numeric{value};" | Numeric precision (e.g. (10,2)) |
Primary Key
Required for every database-backed type. The DDDPlugin extracts primary keys from @constraint(type: "primarykey") to generate the GetPrimaryKey() method on the repository implementation.
graphql
id: ID! @constraint(type: "primarykey")Composite Primary Key (for junction tables):
graphql
type UserRole @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
UserID: ID! @constraint(type: "primarykey")
RoleID: ID! @constraint(type: "primarykey")
assignedAt: String
}Check Constraint
The field name in the check expression is automatically wrapped in double quotes by the generator for PostgreSQL compatibility:
go
// In model_generator.go:
value = strings.ReplaceAll(value, field.Name, `\"`+field.Name+`\"`)Examples:
graphql
# Age validation
age: Int @constraint(type: "check", value: "age >= 18")
# Price validation
price: Float! @constraint(type: "check", value: "price > 0")
# String length
description: String @constraint(type: "check", value: "LENGTH(description) <= 500")
# Range validation
rating: Int @constraint(type: "check", value: "rating >= 1 AND rating <= 5")Multiple Constraints
Fields can have multiple @constraint directives. The generator processes all of them and concatenates the GORM tags:
graphql
type Account @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
email: String! @constraint(type: "unique")
balance: Float!
@constraint(type: "default", value: "0.0")
@constraint(type: "check", value: "balance >= 0")
status: String
@constraint(type: "default", value: "active")
@constraint(
type: "check"
value: "status IN ('active', 'suspended', 'closed')"
)
}The generated Go struct for balance would have:
go
Balance float64 `json:"balance" gorm:"default:0.0;check:\"balance\" >= 0;column:balance;"`Mapping Directive: @mapping
The @mapping directive defines relationships between types. The model generator reads these to produce GORM association struct tags.
Syntax
graphql
fieldName: Type @mapping(
type: "relationship_type",
foreignKey: "field_name",
foreignKeyReference: "referenced_field",
mappingTable: "table_name" # only for many-to-many
)Parameters
type: Relationship type —one2one,one2many,many2many,backRefforeignKey: Field containing the foreign keyforeignKeyReference: Field being referencedmappingTable: Junction table name (many-to-many only)
Important: The schema validator enforces that all non-basic field types (types that aren't ID, Int, String, Boolean, Float, Time, JSON, DateTime, Hex, or custom scalars) must have a @mapping directive. This ensures the generator knows how to handle relationships.
Field Types
Standard Scalar Types
| GraphQL Type | Go Type | PostgreSQL Type | Description |
|---|---|---|---|
ID | string | VARCHAR | Unique identifier |
String | string | TEXT | Variable-length text |
Int | int | INTEGER | 32-bit integer |
Float | float64 | DOUBLE PRECISION | Floating-point number |
Boolean | bool | BOOLEAN | True/false value |
Time | int64 | BIGINT | Unix timestamp |
DateTime | string | TEXT | Date-time string |
JSON | json.RawMessage | JSONB | JSON data (type:jsonb GORM tag) |
Hex | string | TEXT | Hexadecimal string |
Custom Scalars with Database Mapping
These scalars have automatic PostgreSQL type mapping defined in the generator's customScalarToGormTag map:
| Scalar | Go Type | PostgreSQL Type | GORM Tag |
|---|---|---|---|
UUID | string | uuid | type:uuid;default:uuid_generate_v4() |
ULID | string | uuid | type:uuid;default:generate_ulid() |
Point | model.Point | geometry(POINT,4326) | type:geometry(POINT,4326) |
LineString | model.LineString | geometry(LINESTRING,4326) | type:geometry(LINESTRING,4326) |
Polygon | model.Polygon | geometry(POLYGON,4326) | type:geometry(POLYGON,4326) |
Collection | model.Collection | geometry(GEOMETRYCOLLECTION) | type:geometry(GEOMETRYCOLLECTION) |
Array Types
The DDK supports both 1D and 2D PostgreSQL arrays:
| Scalar | PostgreSQL Type | GORM Tag |
|---|---|---|
Int1DArray | integer[] | type:integer[] |
String1DArray | text[] | type:text[] |
Float1DArray | float[] | type:float[] |
Bool1DArray | bool[] | type:bool[] |
Int2DArray | integer[][] | type:integer[][] |
String2DArray | text[][] | type:text[][] |
Float2DArray | float[][] | type:float[][] |
Bool2DArray | bool[][] | type:bool[][] |
Usage:
graphql
type Sensor @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
tags: String1DArray
readings: Float2DArray
location: Point
boundary: Polygon
}Nullable vs Non-Nullable
graphql
# Required field (non-nullable)
name: String!
# Optional field (nullable)
bio: String
# Required list (list can't be null, but can be empty)
tags: [String]!
# Optional list
categories: [String]
# Required list of required items
emails: [String!]!Field Naming
- Use camelCase for field names:
firstName,emailAddress - Field names become column names in the database (usually snake_case via GORM)
- Avoid SQL keywords:
user,order,select, etc.
Enums
Define enumeration types for fields with fixed value sets.
Basic Enum
graphql
enum UserRole {
ADMIN
USER
GUEST
}
type User @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
role: UserRole!
}Multiple Enums
graphql
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
enum PaymentStatus {
UNPAID
PAID
REFUNDED
FAILED
}
type Order @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
orderStatus: OrderStatus!
paymentStatus: PaymentStatus!
total: Float!
}Enum Best Practices
- Use UPPERCASE for enum values
- Be descriptive but concise
- Consider future values when designing
- Don't remove values in production (add new ones instead)
Custom Scalars
JSON Scalar
Store arbitrary JSON data:
graphql
type User @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
name: String!
metadata: JSON
preferences: JSON
}Usage in Mutations:
graphql
mutation {
createUser(
id: "1"
name: "John"
metadata: "{\"age\": 30, \"city\": \"Sydney\"}"
preferences: "{\"theme\": \"dark\", \"notifications\": true}"
) {
id
metadata
preferences
}
}Custom Scalar Definition
You can extend with custom scalars in your schema:
graphql
scalar DateTime
scalar URL
scalar Email
type Event @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
title: String!
startsAt: DateTime!
website: URL
}Note: You'll need to implement custom scalar handling in generated code.
Relationships
One-to-One
One entity relates to exactly one other entity.
Example: User has one Profile
graphql
type User @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
name: String!
email: String!
profile: Profile!
@mapping(type: "one2one", foreignKey: "userId", foreignKeyReference: "id")
}
type Profile @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
userId: String! @constraint(type: "unique")
bio: String
avatar: String
}Critical: The foreign key (userId) must have a unique constraint.
One-to-Many
One entity relates to many other entities.
Example: User has many Posts
graphql
type User @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
name: String!
posts: [Post]!
@mapping(
type: "one2many"
foreignKey: "authorId"
foreignKeyReference: "id"
)
}
type Post @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
title: String!
content: String!
authorId: String
}Notes:
- Foreign key is on the "many" side (Post)
- Foreign key should match the referenced field type
- Foreign key field is typically nullable
Many-to-Many
Many entities relate to many other entities through a junction table.
Example: Users have many Roles, Roles have many Users
graphql
type User @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
name: String!
roles: [Role]!
@mapping(
type: "many2many"
foreignKey: "id"
foreignKeyReference: "id"
mappingTable: "UserRole"
)
}
type Role @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
name: String!
description: String
users: [User]!
@mapping(
type: "many2many"
foreignKey: "id"
foreignKeyReference: "id"
mappingTable: "UserRole"
)
}
type UserRole @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
UserID: ID! @constraint(type: "primarykey")
RoleID: ID! @constraint(type: "primarykey")
}Critical:
- Define explicit junction table
- Use composite primary key
- Field names in junction table follow pattern:
{Type}ID
Back Reference
Navigate relationships in reverse direction.
Example: Profile can navigate back to User
graphql
type User @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
name: String!
contact: Contact!
@mapping(type: "one2one", foreignKey: "userId", foreignKeyReference: "id")
}
type Contact @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
userId: String! @constraint(type: "unique")
phone: String
address: String
# Back reference to user
user: User!
@mapping(type: "backRef", foreignKey: "id", foreignKeyReference: "userId")
}Usage:
graphql
query {
getContact(id: "1") {
phone
address
user {
id
name
email
}
}
}Views and Materialized Views
The DDK supports creating PostgreSQL views and materialized views directly from schema directives. The model generator's checkifTableRequired function processes these.
View Syntax
graphql
type ActiveUsers
@required(
type: "READ"
table: "false"
view: "true"
viewType: "VIEW"
query: "SELECT * FROM users WHERE is_active = true"
) {
id: ID!
name: String!
email: String!
}Materialized View Syntax
graphql
type UserStats
@required(
type: "READ"
table: "false"
view: "true"
viewType: "MVIEW"
query: "SELECT role, COUNT(*) as user_count FROM users GROUP BY role"
) {
role: String!
userCount: Int!
}Implementation: The MigrateModels function in service/model_migrator.go creates views after table migration:
- Views:
CREATE OR REPLACE VIEW {schema}."{name}" AS {query} - Materialized Views:
CREATE MATERIALIZED VIEW {schema}."{name}" AS {query}
Types with view: "true" are added to the ignoreModels map (no table creation) and tracked in the views list for SQL generation.
Refreshing Materialized Views: The legacy repository's QueryParams.Refresh flag triggers REFRESH MATERIALIZED VIEW CONCURRENTLY when set to true.
Schema Validation Rules
The DDK enforces validation rules through schema_validator.go (1347 lines). Validation is two-phase:
Phase 1: Standard gqlgen Validation
Loads the gqlgen configuration, adds internal.graphqls directive declarations, and validates through config.LoadSchema(). This catches:
- Invalid GraphQL syntax
- Undefined type references
- Invalid directive usage
- Duplicate definitions
Phase 2: Custom DDK Validation
CustomValidation() enforces DDK-specific rules:
| Rule | Limit | Error |
|---|---|---|
| Type name length | ≤ 60 characters | "type name exceeds 60 characters" |
| Enum value length | ≤ 200 characters | "enum value exceeds 200 characters" |
| Field name length | ≤ 60 characters | "field name exceeds 60 characters" |
| Go keyword check | Against keywords.json goList | "field/type is a Go keyword" |
| PostgreSQL keyword check | Against keywords.json postgresList | "field/type is a PostgreSQL keyword" |
@required presence | All non-Q/M/S types | "type must have @required directive" |
@mapping for complex types | Non-scalar field types | "non-basic types require @mapping" |
| JSON mandatory check | JSON fields with ! | "JSON fields must not be mandatory" |
Predefined Data Types
The validator recognizes these as built-in scalar types (in initDatatypes()): ID, Int, String, Boolean, Float, Time, JSON, DateTime, Hex
Fields using any other type (except enums and other defined types) require a @mapping directive.
Auto-Generated Resolvers
The DDK auto-generates default CRUD resolvers from schema types using CreateDefaultResolvers() in default_resolver_builder.go. This happens before gqlgen runs.
How Resolvers Are Generated
- The schema text is parsed to extract types with
@requireddirective - For each type, the CRUD flags are checked (
CREATE,READ,UPDATE,DELETE) - Query/Mutation fields are generated with appropriate parameters
Generated Query Format
For a type with READ:
graphql
type Query {
getUser(id: ID!): User
getUserList(
limit: Int
offset: Int
sortBy: String
sortOrder: String
# Filter parameters for each scalar field:
name: String
email: String
age: Int
isActive: Boolean
): [User]
}Generated Mutation Format
For CREATE:
graphql
type Mutation {
createUser(id: ID!, name: String!, email: String!, age: Int): User
}For UPDATE:
graphql
type Mutation {
updateUser(id: ID!, name: String, email: String, age: Int): User
}For DELETE:
graphql
type Mutation {
deleteUser(id: ID!): Boolean
}Type Mapping in Resolvers
The resolver builder maps GraphQL types to query parameter types:
- Enum types are used directly as parameters
- Geospatial types are converted to input types:
Point→InputPoint,LineString→InputLineString,Polygon→InputPolygon,Collection→InputCollection @format(type: "...")directive can specify custom type mappings likeULID,UUID,Hex- Array types and object references are excluded from query filters
Best Practices
Schema Design
- Start Simple: Begin with core entities, add relationships later
- Primary Keys Always: Every database table needs a primary key
- Use Enums: For fixed value sets instead of strings
- Nullable Wisely: Required fields (
!) enforce data integrity - Unique Constraints: Use for fields that must be unique (emails, usernames)
Relationship Design
- One-to-One: Always use unique constraint on foreign key
- One-to-Many: Foreign key on the "many" side
- Many-to-Many: Always create explicit junction table with composite primary key
- Back References: Use sparingly to avoid circular dependencies
Constraints
- Validate at Database Level: Use check constraints for data integrity
- Default Values: Set sensible defaults for optional fields
- Check Constraints: Enforce business rules at database level
- Multiple Constraints: Combine for comprehensive validation
Naming Conventions
- Types: PascalCase (
User,OrderItem) - Fields: camelCase (
firstName,createdAt) - Enums: UPPERCASE (
ADMIN,PENDING) - Junction Tables: Combine type names (
UserRole,PostTag)
Performance
- Indexes: Add unique constraints which create indexes
- Eager Loading: Use
@mappingfor efficient relationship queries - Pagination: Always provide list queries with limit/offset
- JSON Fields: Use sparingly for unstructured data
Schema Evolution
- Adding Fields: Safe, no migration issues
- Removing Fields: Create migration to drop column
- Changing Types: Requires careful migration
- Adding Relationships: Add foreign key constraints carefully
Testing Schema Changes
- Validate schema before generation
- Test in development environment first
- Review generated migrations
- Test queries with new schema
- Check for breaking changes in API
Common Patterns
Audit Fields
The DDK model generator automatically recognizes three special field names (createdAt, updatedAt, deletedAt) defined in autoDBFields. When these fields appear in your schema, they receive standard GORM tags:
createdAt→CreatedAt intwithgorm:"column:createdAt;"— Set to current time on creationupdatedAt→UpdatedAt intwithgorm:"column:updatedAt;"— Set to current unix seconds on updatedeletedAt→DeletedAt intwithgorm:"column:deletedAt;"— Populated on soft delete
graphql
type User @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
name: String!
email: String!
createdAt: String @constraint(type: "default", value: "now()")
updatedAt: String @constraint(type: "default", value: "now()")
createdBy: String
updatedBy: String
}Soft Deletes
graphql
type User @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
name: String!
email: String!
deletedAt: String # Populated on delete
}Note: DDK automatically handles soft deletes via GORM.
Multi-tenant Schema
graphql
type Organization @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
name: String!
users: [User]!
@mapping(
type: "one2many"
foreignKey: "organizationId"
foreignKeyReference: "id"
)
}
type User @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
name: String!
organizationId: String!
}Hierarchical Data
graphql
type Category @required(type: "CREATE,READ,UPDATE,DELETE", table: "true") {
id: ID! @constraint(type: "primarykey")
name: String!
parentId: String
parent: Category
@mapping(type: "backRef", foreignKey: "id", foreignKeyReference: "parentId")
}Validation Checklist
Before generating your server, verify:
- [ ] All types have
@requireddirective - [ ] All database tables have primary key
- [ ] Foreign keys match referenced field types
- [ ] One-to-one relationships have unique constraint
- [ ] Many-to-many relationships have junction table
- [ ] Junction tables have composite primary keys
- [ ] Check constraints use valid SQL
- [ ] Default values are appropriate for field types
- [ ] Enum values are uppercase
- [ ] No circular dependencies in back references
- [ ] Field names follow conventions
- [ ] Required fields use
!notation
Related Documentation
- Architecture - Understanding generated code
- Custom Resolvers - Extending functionality
- Examples - Real-world schema examples
- FAQs - Common questions
