ADR-005: Input Validation Strategy
Status
Accepted - 2025-12-24
Update (2026-02-14): Replaced custom
createValidationInterceptorwith the official@connectrpc/validatepackage (createValidateInterceptor()). Custom implementation removed. Interceptor chain order revised — validation is now 7th (before serializer), not 1st. See Implementation section.
Context
Target Environment
Embedded devices in production:
- Critical industrial/medical systems (high reliability requirements)
- Long-running processes (months/years without restart)
- No remote debugging (isolated networks)
- Expensive physical access (embedded devices in the field)
Quality requirements:
- Target uptime: 99.9%+ (mission-critical systems)
- Zero tolerance for crashes — failures can have serious consequences
- High confidence in releases — no ability to quick-fix in production
- Must catch bugs before deployment
Why Input Validation is Critical (P0)
In this environment, input validation is the primary defense mechanism:
- No Auth/Authz Layer — no traditional authentication protection
- Direct Service Access — clients have direct access to services
- Malformed Data Risk — invalid input can cause crashes or undefined behavior
- Data Integrity — critical for embedded systems (robotics, industrial)
| Mechanism | Priority | Status |
|---|---|---|
| Input Validation | P0 (Critical) | This ADR |
| TLS Encryption | Optional | ADR-004 (internal) |
| Rate Limiting | Not Required | Controlled environment |
| Authentication | Not Required | Isolated network |
| Authorization | Not Required | Trusted devices |
Requirements
- Schema-Based Validation — rules must be part of proto schemas
- Automatic Enforcement — validation happens automatically for all requests
- Fail Fast — invalid requests rejected before business logic
- Clear Error Messages — clients receive understandable validation errors
- Performance — validation overhead < 1ms per request
- Extensibility — custom validation rules possible
Decision
Use @connectrpc/validate (official ConnectRPC validation package backed by @bufbuild/protovalidate) for schema-based input validation, integrated into the default interceptor chain.
Solution Architecture
Proto Schema with Validation Constraints
syntax = "proto3";
import "buf/validate/validate.proto";
message CreateOrderRequest {
string customer_id = 1 [(buf.validate.field).string.min_len = 1];
repeated OrderItem items = 2 [(buf.validate.field).repeated.min_items = 1];
ShippingAddress shipping_address = 3 [(buf.validate.field).required = true];
string currency = 4 [(buf.validate.field).string = {min_len: 3, max_len: 3}];
}
message OrderItem {
string product_id = 1 [(buf.validate.field).string.min_len = 1];
string name = 2 [(buf.validate.field).string.min_len = 1];
int32 quantity = 3 [(buf.validate.field).int32.gt = 0];
int64 price_cents = 4 [(buf.validate.field).int64.gt = 0];
}
message GetOrderRequest {
string order_id = 1 [(buf.validate.field).string.uuid = true];
}
message ListOrdersRequest {
int32 page_size = 1 [(buf.validate.field).int32 = {gte: 1, lte: 100}];
string page_token = 2;
}Available constraints: min_len, max_len, pattern, email, uri, uuid (string); lt, lte, gt, gte, in, not_in (numeric); min_items, max_items, unique (repeated); required, skip (message); defined_only (enum).
buf.yaml Configuration
Proto files that use validation constraints must declare the dependency:
# buf.yaml
version: v2
modules:
- path: proto
deps:
- buf.build/bufbuild/protovalidate
lint:
use:
- STANDARD
breaking:
use:
- FILEImplementation
Package: @connectrpc/validate
Validation is delegated to the official ConnectRPC package. No custom interceptor implementation exists in Connectum.
Dependencies (in @connectum/interceptors):
{
"dependencies": {
"@connectrpc/validate": "catalog:"
}
}Catalog (in pnpm-workspace.yaml):
catalog:
'@connectrpc/validate': ^0.2.0
'@bufbuild/protovalidate': ^1.1.1@connectrpc/validate has peer dependencies on @bufbuild/protobuf (^2.9.0), @bufbuild/protovalidate (^1.0.0), and @connectrpc/connect (^2.0.3).
Integration with createDefaultInterceptors()
Location: packages/interceptors/src/defaults.ts
import { createValidateInterceptor } from "@connectrpc/validate";
// Inside createDefaultInterceptors():
if (options.validation !== false) {
interceptors.push(createValidateInterceptor());
}Configuration accepts only boolean:
export interface DefaultInterceptorOptions {
/** Validates request messages using @connectrpc/validate. @default true */
validation?: boolean;
// ...
}For custom validation configuration, use createValidateInterceptor() directly:
import { createValidateInterceptor } from "@connectrpc/validate";
const server = createServer({
services: [routes],
interceptors: [
createValidateInterceptor(/* custom options */),
// ... other interceptors
],
});Interceptor Chain Order
The default chain order is fixed (see ADR-023):
1. errorHandler — Catch-all error normalization (outermost, must be first)
2. timeout — Enforce deadline before any processing
3. bulkhead — Limit concurrency
4. circuitBreaker — Prevent cascading failures
5. retry — Retry transient failures (exponential backoff)
6. fallback — Graceful degradation (DISABLED by default)
7. validation — @connectrpc/validate (createValidateInterceptor)
8. serializer — JSON serialization (innermost)Rationale for validation position (7th, not 1st):
The original ADR proposed validation as the first interceptor ("reject invalid data immediately"). The current implementation places it after resilience interceptors because:
- Error handler must be outermost — validation errors (ConnectError with
INVALID_ARGUMENT) need consistent error formatting, which errorHandler provides as the outermost wrapper - Timeout protects validation — if validation itself is slow (complex constraints), timeout will abort it
- Validation before serializer — data is validated before JSON serialization, ensuring only valid data reaches the handler
- Resilience is infrastructure — timeout, bulkhead, circuit breaker protect the system regardless of payload validity
Usage
import { createServer } from '@connectum/core';
import { createDefaultInterceptors } from '@connectum/interceptors';
// Validation enabled by default
const server = createServer({
services: [routes],
interceptors: createDefaultInterceptors(),
});
// Disable validation
const server = createServer({
services: [routes],
interceptors: createDefaultInterceptors({ validation: false }),
});Error Response
When validation fails, @connectrpc/validate throws a ConnectError with code INVALID_ARGUMENT containing structured violation details:
Code: INVALID_ARGUMENT
Message: "customer_id: value length must be at least 1 characters [string.min_len]"Consequences
Positive
Zero Custom Code — validation logic is fully delegated to the official
@connectrpc/validatepackage. No maintenance burden for custom interceptor.Consistent with Ecosystem — uses the same validation library as the rest of the ConnectRPC/buf ecosystem.
Schema-Based — proto schemas are the single source of truth. Validation rules are co-located with message definitions.
Automatic Enforcement — enabled by default in
createDefaultInterceptors(). All requests are validated without developer action.Clear Error Messages — clients receive structured
INVALID_ARGUMENTerrors with per-field violation details.Performance — validation overhead < 1ms per request for typical messages.
Negative
Proto File Complexity — proto files become more verbose with constraint annotations. Mitigated: constraints are self-documenting and easier to read than manual validation code.
Limited Custom Validation —
buf.validateprovides a predefined constraint set. Business-level validation (e.g., "email must be unique") still requires service-layer code.Boolean-Only Config —
createDefaultInterceptors()accepts onlybooleanfor validation. Custom configuration requires direct use ofcreateValidateInterceptor().Upstream Dependency — relies on
@connectrpc/validateand@bufbuild/protovalidate. Breaking changes upstream would affect Connectum.
Alternatives Considered
| # | Alternative | Rating | Why Rejected |
|---|---|---|---|
| 1 | Manual validation in service handlers | 2/10 | Error-prone, inconsistent, boilerplate, does not scale |
| 2 | Joi/Zod runtime validation | 6/10 | Duplicate schemas (proto + Zod), schema drift risk, proto should be single source of truth |
| 3 | Custom createValidationInterceptor | 7/10 | Was the original decision. Replaced by official @connectrpc/validate — less maintenance, better ecosystem compatibility |
| 4 | @connectrpc/validate (chosen) | 9/10 | Official package, zero custom code, ecosystem standard, maintained by ConnectRPC team |
Migration
The custom validation interceptor was removed in favor of @connectrpc/validate:
// BEFORE (custom, removed)
import { createValidationInterceptor } from "@connectum/interceptors";
const interceptor = createValidationInterceptor({ skipStreaming: true });
// AFTER (official)
import { createValidateInterceptor } from "@connectrpc/validate";
const interceptor = createValidateInterceptor();
// Or via default chain (recommended)
import { createDefaultInterceptors } from "@connectum/interceptors";
const interceptors = createDefaultInterceptors(); // validation enabled by defaultReferences
- @connectrpc/validate — official ConnectRPC validation interceptor
- Buf Validate — constraint library and reference
- ConnectRPC Interceptors
- OWASP Input Validation Cheat Sheet
- ADR-023: Uniform Registration API — interceptor chain order
Changelog
| Date | Author | Change |
|---|---|---|
| 2025-12-24 | Claude | Initial ADR — custom createValidationInterceptor, validation-first chain order |
| 2026-02-14 | Claude | Replaced custom interceptor with @connectrpc/validate; updated chain order (7th, not 1st); added migration guide; real proto examples from production-ready example |
