
A video generation request looks simple from the outside: a prompt goes in, a rendered clip comes out. Behind that interface sits a pipeline with many distinct stages — prompt normalization, safety screening, model selection, GPU allocation, frame synthesis, frame interpolation, audio alignment, encoding, thumbnail extraction, and delivery. Each stage has its own latency profile, its own failure modes, and its own scaling requirements. Frame synthesis may take minutes while prompt normalization takes milliseconds. A safety rejection is instant and final; a network timeout is slow and possibly temporary.
When all of that logic lives in a single service class, every change becomes a negotiation with the whole system. Someone tightens prompt validation in one method and quietly changes the payload sent to an encoder in another. A new engine integration rewires queue scheduling as a side effect. Debugging turns into archaeology: you follow a stack trace through thousands of lines and still cannot tell which component owns the failure.
NestJS is a useful answer to this problem because it gives structure without forcing distribution. Modules, providers, controllers, and dependency injection create explicit seams inside a single deployable application. You keep the operational simplicity of one codebase and the conceptual clarity of separated capabilities. For GPU-heavy work, that matters. Nobody wants to debug a distributed tracing setup while a render farm sits idle because a scheduling bug stranded jobs in the wrong queue.
The goal is not architectural purity. The goal is predictability under stress: when a provider times out, when a worker dies mid-render, when a burst of submissions arrives at once, when a new engine needs to be added without touching the orchestration layer. Modules make dependencies visible, and visible dependencies are debuggable.
Most teams start with layer-based modules: a ServicesModule, a ControllersModule, a RepositoriesModule. This feels tidy for a week and becomes a tangle within a month. Every service ends up depending on every other service, and the module graph stops describing the product. Feature modules work better for video generation because the domain is already divided into capabilities that different people care about.
A practical module map for an AI video backend looks like this:
Each module owns its controllers, services, and data access. Cross-module communication happens through exported facades or domain events, never by reaching into another module's internals.
A useful test: if you deleted a feature from the product roadmap, how many folders would you touch? With feature modules, the answer is usually one. With layer modules, the answer is everywhere. Video generation changes often — new engines appear, supported durations shift, audio features land — so the blast radius of a change should stay small.
Boundaries only exist if something enforces them. Export only what other modules genuinely need. Keep ORM entities private to the module that owns them and expose a facade service instead of a repository. If a worker needs to react to a completed render, publish an event rather than importing the generation service. A common cycle looks like this: MediaStorage imports NotificationModule to announce an upload, NotificationModule imports VideoGenerationModule to look up job details, and VideoGenerationModule imports MediaStorageModule to save renders. The cycle disappears the moment MediaStorage emits an event and someone else subscribes.
Begin with three modules: generation, providers, and queues. Add storage and prompt modules once the first integration is stable. Add metering and observability before you scale workers, not after an incident forces you to. Modules can be introduced incrementally; you do not need the full map on day one.
Long-running jobs should never be handled inside a request cycle. A submission endpoint should validate input, create a durable job record, enqueue work, and return immediately with a job identifier and a status URL. If the HTTP handler waits on a GPU, timeouts, retries, and load balancer behavior all conspire against you, and a single slow render can exhaust your connection pool.
The controller stays boring and small:
// video-generation.controller.ts
@Controller('renders')
export class VideoGenerationController {
constructor(private readonly generation: VideoGenerationService) {}
@Post()
async create(@Body() body: CreateRenderDto, @Headers('idempotency-key') key: string) {
return this.generation.enqueue(body, key);
}
@Get(':id')
async status(@Param('id') id: string) {
return this.generation.getStatus(id);
}
}
The service does the orchestration and delegates the heavy work:
// video-generation.service.ts
async enqueue(dto: CreateRenderDto, idempotencyKey: string) {
const existing = await this.jobs.findByKey(idempotencyKey);
if (existing) return this.toResponse(existing);
const job = await this.jobs.create(dto, idempotencyKey);
await this.queue.add('render-standard', { jobId: job.id }, { attempts: 3 });
return this.toResponse(job);
}
Notice that the service never speaks to a model SDK directly. It speaks to a queue. That single decision is what allows you to change queue infrastructure, swap engines, or reorder pipeline stages later without rewriting the API surface.
Validate at the edge so invalid jobs never reach a GPU: prompt length limits, supported aspect ratios, duration bounds, allowed source asset formats, and a whitelist of model identifiers. Reject unknown model names instead of silently falling back, unless the fallback is explicitly requested. Cheap validation upstream saves expensive failures downstream.
Rendering is expensive, and clients retry. Accept an idempotency key, store it alongside the job record with a unique constraint, and return the existing job when the same key arrives again. Make the uniqueness check part of the database transaction, not a read-then-write sequence, so two concurrent requests cannot both create a job.
When queue depth crosses a threshold, either reject new submissions with a clear retry hint or accept them into a delayed state with an honest position estimate. Silent acceptance followed by hour-long waits damages trust more than an explicit refusal.
The model layer is the most volatile part of the system. Engines change their APIs, deprecate parameters, and add capabilities. If generation code imports each SDK directly, every integration becomes a cross-cutting change that touches orchestration, validation, and storage.
Define one interface and push engine-specific details into adapters:
// model-provider.interface.ts
interface VideoModelProvider {
readonly name: string;
readonly capabilities: ProviderCapabilities;
submit(input: ModelInput): JobHandle;
poll(handle: JobHandle): JobStatus;
cancel(handle: JobHandle): void;
normalizeError(error: unknown): ModelError;
}
One adapter may call a remote API and return a remote job identifier. Another may enqueue work onto an internal GPU cluster. A third may wrap a self-hosted inference server with a custom scheduling policy. The generation service only knows the interface, so it does not care where the pixels are produced.
Put capabilities in the adapter, not in scattered conditionals. Each provider should declare maximum duration, supported aspect ratios, whether it accepts an image seed, whether it supports audio tracks, typical latency, and concurrency limits. The API can then expose only valid combinations to clients, and validation can reject impossible requests before they consume queue space.
A registry service holds registered adapters and resolves them by name. It also gives you one place to implement fallbacks: if a preferred engine is unavailable or unhealthy, the registry can route to an approved alternative while the caller stays unchanged. Registration can happen through a factory provider that collects adapters during module initialization.
Timeouts, retry policies, poll intervals, base URLs, and concurrency limits belong to each adapter, not to a global configuration object. That way you can tune one engine without risking another. It also makes adapters easy to test: instantiate one with test configuration, feed it a fake transport, and assert how it handles a slow response, a malformed payload, or an unexpected status code.
Queues are the spine of an asynchronous video pipeline. They decouple intake from processing, provide retries, and let you scale workers independently of the API. A workable layout separates jobs by cost rather than by engine:
Separating heavy jobs from previews prevents a batch submission from blocking interactive feedback. Different queues can also carry different concurrency limits, which is how you protect GPU memory from oversubscription.
// task-queue.module.ts
@Module({
imports: [
BullModule.registerQueue(
{ name: 'render-preview' },
{ name: 'render-standard' },
{ name: 'render-heavy' },
),
],
providers: [RenderWorker],
exports: [BullModule],
})
export class TaskQueueModule {}
Not every failure deserves another attempt. A malformed prompt, a policy rejection, or an unsupported parameter combination will fail identically forever. A network timeout, a rate limit response, or a temporary provider outage usually will not.
| Error class | Retry | Backoff | Handling |
|---|---|---|---|
| Network timeout | Yes | Exponential with jitter | Retry up to limit, then dead-letter |
| Rate limit | Yes | Respect retry hint | Reduce concurrency for that provider |
| Policy rejection | No | None | Mark terminal, surface reason to user |
| Invalid input | No | None | Fail fast at validation layer |
| Provider outage | Yes | Longer backoff | Consider fallback engine |
| Corrupt output | Sometimes | Once | Re-run the affected stage only |
Set concurrency per queue and per provider. External APIs have request ceilings that a runaway worker pool will hit within minutes. Internal GPU pools have memory ceilings that produce out-of-memory crashes rather than clean errors. Measure both and encode the limits in configuration so they can be adjusted without redeploying code.
Cancellation is not only a user-experience feature. When someone abandons a queued render, that job should stop consuming compute. Mark the job cancelled, remove it from the queue if it has not started, and call the provider's cancel method when the engine supports it. For engines that cannot cancel, treat the job as abandoned and discard the result on arrival instead of running post-processing.
Every outbound call needs a timeout, including polling requests that look harmless. A provider that stops responding mid-poll can hold a worker open indefinitely if there is no deadline. Pair timeouts with a maximum poll duration so a stalled job eventually moves to a failed state rather than living forever in processing.
Dependency injection is a coupling-control tool, not just a convenience. When a service receives its collaborators through the constructor, its requirements are visible in one place, and its behavior is testable without a database, a queue, or a GPU.
TypeScript interfaces vanish at runtime, so use injection tokens for contracts. Use factory providers when a dependency needs asynchronous setup, such as a database connection, a signed client, or a model registry populated at startup.
// model-provider.module.ts
@Module({
providers: [
ModelRegistryService,
RemoteVideoProvider,
LocalGpuProvider,
{
provide: 'VIDEO_MODEL_PROVIDERS',
useFactory: (remote: RemoteVideoProvider, local: LocalGpuProvider) => [remote, local],
inject: [RemoteVideoProvider, LocalGpuProvider],
},
],
exports: [ModelRegistryService],
})
export class ModelProviderModule {}
Request-scoped providers create a new instance per request, which can be convenient for tenant context but costly under high throughput. Prefer passing context explicitly through method arguments, or use async local storage for correlation identifiers and tenant metadata that must travel deep into the call stack without polluting every signature.
Create fake providers that simulate success, timeout, policy rejection, and cancellation. Use them in integration tests to walk a job through its full lifecycle in milliseconds. Reserve real provider calls for a small contract suite that runs less often, and keep those tests focused on shape and error mapping rather than on the artistic quality of output.
Injecting a container and pulling dependencies at runtime hides the dependency graph and breaks testability. If a service needs five collaborators, list five constructor parameters. The awkwardness is a signal that the class is doing too much.
Video generation is inherently asynchronous, and completion signals can arrive late, out of order, or twice. Webhooks are fast and efficient but can be missed. Polling is predictable but wasteful if used as the only mechanism. The pragmatic answer is webhooks as the primary signal with polling as reconciliation.
Give providers a dedicated callback controller. Verify signatures, normalize payloads into an internal event shape, enqueue an internal job, and return quickly. Webhook handlers should not contain business logic; they should translate and hand off.
Define a small set of states: queued, submitted, processing, post-processing, completed, failed, cancelled. Make transitions explicit and store timestamps plus the provider handle for every step. A job should never jump from queued to completed. When support asks why a render took nine minutes, the timestamps answer the question without guesswork.
A completion event may arrive after a polling worker has already marked a job as failed, or a duplicate completion may arrive twice. Make updates idempotent and conditional: only apply a transition if the current state allows it. Wrap the check and update in a transaction so racing writers cannot both win.
Progress percentages are estimates, not facts. If an engine does not report granular progress, show stage names instead of a fabricated percentage. Stage-level honesty reduces support load and avoids the situation where a bar sits at 92 percent for four minutes.
Rendered media is large and should never live in the primary database. Store blobs in object storage with lifecycle rules, keep metadata in the database, stream uploads instead of buffering whole files, and deliver private results through short-lived signed links. Temporary assets need aggressive expiration; final renders need a retention policy that someone has actually agreed to.
Track queue depth per queue, job duration percentiles, failure rate per provider, retry counts, webhook lag, worker saturation, and the gap between submission and first visible progress. Add a correlation identifier to every job so a single render can be followed from API intake to final delivery in logs and traces. Aggregate metrics answer whether the system is healthy; correlation identifiers answer why one specific job failed.
Jobs get stuck. Workers crash, providers go silent, and deployments interrupt long renders. Provide an administrative path to inspect, requeue, or cancel any job. Use a dead-letter queue for jobs that exceed retry limits so they do not silently disappear. Where a provider supports resumption, store enough intermediate state to continue from the last successful stage. Where it does not, design each stage to be idempotent so a full re-run does not duplicate delivered assets.
Thumbnails, intermediate frames, and debug artifacts accumulate quickly. Apply lifecycle rules that move intermediates to cheaper storage or delete them after a fixed window. Keep the delivered render and its metadata, and treat everything else as disposable.
Scaling a video backend is not simply adding workers. Split the API deployment from the worker deployment. Scale API instances on request rate and latency. Scale workers on queue depth and provider capacity, using custom metrics rather than CPU alone, because a worker waiting on a remote engine looks idle while doing important work.
Unit-test orchestration logic with fakes. Run contract tests that verify every adapter maps errors consistently and honors timeouts. Add integration tests with fake providers for the full lifecycle including retries and cancellation. Use load tests to observe queue behavior under bursts. Test the ugly cases on purpose: duplicate webhooks, cancelled jobs that still return results, providers that succeed but return an empty file.
Most of these are boundary failures rather than syntax failures. The framework will not stop you from building a tangle; it only gives you the tools to avoid one.
Yes, especially if you want modular structure without committing to microservices. Modules, dependency injection, guards, interceptors, and a mature queue ecosystem cover most of what an asynchronous rendering pipeline needs.
Usually not. A modular monolith is easier to develop, test, and deploy. Extract services when you have a concrete reason, such as independent scaling of GPU workers or separate team ownership. Many teams get the scaling they need simply by deploying API and worker processes separately.
Write an adapter that implements your provider interface, declare its capabilities, register it in the registry, and keep its configuration inside its own module. The generation service should not change at all.
Require an idempotency key, enforce uniqueness with a database constraint, and return the original job when the key repeats. Apply the same idempotent pattern to webhook and polling updates.
Use webhooks as the primary completion signal and polling as a reconciliation fallback. Always verify webhook signatures and normalize payloads before touching job state.
Build fake providers that simulate success, timeout, policy rejection, cancellation, and corrupt output. Drive integration tests with those fakes and keep real engine calls in a smaller, slower contract suite.
Queue depth, job duration percentiles, failure rates per provider, retry counts, webhook lag, and the number of jobs sitting in dead-letter. Those six numbers reveal most real problems.
Set a maximum poll duration per stage. When it expires, mark the job failed, notify the owner, and route the job to dead-letter so an operator can requeue or cancel it deliberately.
With clear module boundaries, a stable provider interface, disciplined queue policies, and honest state tracking, a NestJS backend can stay readable even as the number of engines and workflow options grows. The architecture becomes a map of the product rather than a list of files, and new capabilities slot into places that already make sense.

Alexander
Vidu Q4 alternatives compared: Kling 4.0, Seedance 2.5, Wan 3.0, and Runway Gen-4 — 4K support, input modes, durations, audio, and what each costs in credits on Domer.
The top 4K AI image generators on Domer compared: Flux 3 Klein, Nano Banana 2.1, GPT Image 2.5, GPT Image 2, and Nano Banana Pro — 4K credit costs, reference limits, and speed.
Nano Banana 2.1 vs GPT Image 2.5 Flare compared: credit costs per resolution, aspect ratios, reference images, text rendering, and which tasks fit each model.