Context
The production platform I work on at Formilo is built with Amplify Gen 2, AppSync and Lambda. Its schema has 21 models, and the auto-generated CRUD for them covered most of the data model very well: entities, tasks, campaigns and processing jobs could be created, read, updated and deleted exactly as expected.
But many production operations are not "update one record" interactions. They are workflow commands:
- start scoring hundreds of selected records,
- provision a scheduled email queue,
- delete an entire search together with all related processing data.
Problem
Those operations don't map to hundreds of sequential GraphQL calls from the browser. Coordinating them from the client would mean duplicated execution paths, partial failures, inconsistent task state, timeouts and authorization spread across many calls.
There's also a hard physical limit. An AppSync request has a maximum size, and so does the payload of an asynchronous Lambda invocation. A selection of thousands of ids doesn't fit through either.
Solution: CRUD models state, mutations coordinate workflows
A recurring pattern in the schema:
- models describe durable state (a scoring task, an email task, a scrape task),
- custom mutations describe operations (start batch scoring, provision an email queue, delete a search workflow),
- Lambda handlers execute the orchestration,
- the mutation returns a structured result to the client.
From the frontend, the interaction stays simple: one mutation call, one orchestrated backend operation. The browser is responsible for intent; the backend is responsible for orchestration. Today the schema has 25 custom mutations backed by Lambda, out of about 60 Lambda functions in the backend.
Batch scoring is a workflow, not a create()
When a user starts scoring, the frontend doesn't create tasks one by one. A mutation receives the identifiers, the execution parameters and the selection context. Inside Lambda, the workflow roughly looks like this:
- verify that no identical batch job is already active,
- load the selected records with the metadata they need,
- pick the latest valid scrape for each one,
- compare against existing tasks and re-process only records whose source data changed,
- create the task records together with a parent batch job.
The mutation is a typed command boundary: authenticated request → backend orchestration → durable task records → async processing → live UI updates. It follows the same model as queues or schedulers; only the entry point differs.
Deletion is orchestration, too
Deleting a search isn't deleting one DynamoDB item. It means traversing and cleaning a connected graph: processing tasks, generated records, scrape jobs, related entities and finally the parent container. The mutation does this server-side with controlled pagination, chunked deletes, bounded concurrency, central authorization and structured logging. It treats cleanup as a recoverable workflow rather than an atomic transaction: if one step fails, it continues where recovery is still possible.
Guards that only a command can enforce
Some rules can't be expressed as "this user may update this record":
- Guarded delete. The model-level delete for the parent record isn't granted to clients at all. The custom mutation is the only way to delete it, so the cascade can't be skipped.
- Explicit deletion mode. A deletion request states what it deletes. An empty selection must never fall through to a delete-all.
- Field-level authorization. Clients can't un-pause sending by writing the field directly.
- Execution guards such as "already running" and validation checks, so a command isn't a blind executor.
Payload limits
The client sends at most 1,000 ids inline. That keeps requests under the AppSync request size limit and the payload limit for asynchronous Lambda invocations. Larger selections are handled server-side instead of being shipped through the API.
One data layer
The Lambda handlers use the same generated data layer as the frontend (configureAmplify plus the generated client). Orchestration and UI share one contract, and once a mutation has created durable task records, the async infrastructure (Streams, SQS, Scheduler, webhooks) takes over the long-running work.
Trade-offs
- More code to own. A custom mutation needs a schema definition, a handler and its own authorization rules. Generated CRUD needs none of that.
- Response shapes vary. Results like a created count, warnings, a job id or skipped records need a structured JSON response and a client that reads it.
- Not atomic. Multi-step workflows can fail halfway, so they have to be written to resume or recover, not assumed to roll back.
For straightforward entity edits, CRUD remains the right abstraction. Custom mutations become hard to avoid once an operation spans many records, coordinates external systems, needs central failure handling, enforces domain rules, or should evolve independently from the frontend contract.
Result
- Generated CRUD still handles entity state for all 21 models.
- 25 custom mutations form the command layer for everything that behaves like a workflow.
- Destructive operations have a single guarded entry point; an empty selection can't become a delete-all.
- Large selections never travel through the API as giant payloads.
Auto-generated CRUD accelerated most of the backend significantly. The remaining complexity — concurrency control, orchestration, recovery and long-running execution — ended up in custom mutations backed by Lambda.