Querying and CRUD
Morphis gives you two layers for database access:
- use
Modelmethods for normal application CRUD - use the underlying Drizzle instance for more custom queries when the built-in model helpers are not enough
For most projects, put query logic in a service and let the controller call the service.
Example filenames
Use a structure like this:
src/models/Post.tssrc/services/PostService.tssrc/controllers/PostController.ts
1. Model file
import { Model } from 'morphis'
import type { ConnectionName } from '../config/database'
export class Post extends Model {
static connection: ConnectionName = 'default'
static tableName = 'posts'
declare id: number
declare content: string
declare createdAt: string
}This model points to the posts table. Because Morphis normalizes application keys, your code can use createdAt while the database column stays created_at.
2. Query and CRUD in a service
import { eq } from 'drizzle-orm'
import { NotFoundError } from 'morphis'
import { Post } from '../models/Post'
export class PostService {
async list(limit = 20, offset = 0) {
return Post.findAll({ limit, offset })
}
async get(id: number) {
const post = await Post.findByPk(id)
if (!post) {
throw new NotFoundError('Post not found')
}
return post
}
async create(input: { content: string }) {
return Post.create(input)
}
async update(id: number, input: { content: string }) {
const post = await this.get(id)
await post.update(input)
return post
}
async remove(id: number) {
const post = await this.get(id)
await post.destroy()
return post
}
async findWithDrizzle(id: number) {
await Post.initialize()
const query = await Post.find()
const rows = await query.where(eq(Post.table.id, id)).limit(1)
return rows[0] ?? null
}
}
export const postService = new PostService()What each method is doing
Post.findAll()reads many rowsPost.findByPk()reads one row by primary keyPost.create()inserts one rowpost.update()updates the current row instancepost.destroy()deletes the current row instancePost.find()gives you a raw Drizzle query builder for advanced filtering and joins
3. Controller calling the service
import {
Controller,
Delete,
Get,
NotFoundError,
Post as HttpPost,
Put,
Request,
} from 'morphis'
import { postService } from '../services/PostService'
@Controller('posts')
export class PostController {
@Get()
async list(req: Request) {
const limit = Number(req.query.limit ?? 20)
const offset = Number(req.query.offset ?? 0)
return postService.list(limit, offset)
}
@Get(':id')
async get(req: Request) {
const id = Number(req.params.id)
if (!Number.isFinite(id)) {
throw new NotFoundError('Post not found')
}
return postService.get(id)
}
@HttpPost()
async create(req: Request) {
return postService.create(req.body as { content: string })
}
@Put(':id')
async update(req: Request) {
return postService.update(
Number(req.params.id),
req.body as { content: string },
)
}
@Delete(':id')
async remove(req: Request) {
return postService.remove(Number(req.params.id))
}
}This keeps the controller thin. The controller handles HTTP input and output, while the service owns the database logic.
4. Transaction example in a service
Morphis supports explicit transactions directly on the model layer.
import { ConnectionManager, NotFoundError } from 'morphis'
import { AuditLog } from '../models/AuditLog'
import { Post } from '../models/Post'
export class PostService {
async createThenRewrite(content: string) {
const transaction = await ConnectionManager.getTransaction()
try {
const created = await Post.create({ content }, transaction)
if (!created) {
throw new NotFoundError('Post was not created')
}
const updated = await Post.update(
{ content: `${created.content} (rewritten)` },
{ where: { id: created.id }, transaction },
)
await AuditLog.create(
{ message: `rewrote post ${created.id}` },
{ transaction },
)
await transaction.commit()
return updated[0] ?? created
} catch (error) {
await transaction.rollback()
throw error
}
}
async previewDrafts() {
const transaction = await ConnectionManager.getTransaction()
try {
await Post.create({ content: 'draft' }, { transaction })
const rows = await Post.findAll({ transaction })
await transaction.commit()
return rows
} catch (error) {
await transaction.rollback()
throw error
}
}
}Transaction notes
- call
await ConnectionManager.getTransaction()to get a transaction on the default connection - pass a connection name such as
await ConnectionManager.getTransaction('analytics')when you need a non-default database - the same transaction can be reused across multiple Morphis models and tables on that same connection
- using that transaction with a model on a different connection throws an error
- finish every transaction with
await transaction.commit()orawait transaction.rollback() - you can pass the transaction directly, for example
Post.create(data, transaction) - or pass it inside options, for example
Post.findAll({ where: { id: 1 }, transaction }) - instance methods also support transactions, for example
await post.update(data, transaction)andawait post.destroy({ transaction }) find,findAll,findOne,findByPk,create,bulkCreate,update,destroy,count,findAndCountAll, andupsertall accept transactions- explicit transactions are not supported for Cloudflare D1 bindings; local D1 via SQLite storage is supported
5. When to use which layer
Use model methods when:
- you are doing straightforward CRUD
- you want explicit transaction control with Morphis models
- you want the Morphis key normalization between
camelCaseandsnake_case - you want simple, readable service code
Use raw Drizzle access when:
- you need joins or more custom SQL composition
- you want direct access to the query builder beyond the built-in model helpers
Further reading
Last updated on