Skip to content

Instantly share code, notes, and snippets.

@graffhyrum
Last active July 17, 2025 16:07
Show Gist options
  • Select an option

  • Save graffhyrum/4ea1035a3c680227d7b34d0ee25f94ac to your computer and use it in GitHub Desktop.

Select an option

Save graffhyrum/4ea1035a3c680227d7b34d0ee25f94ac to your computer and use it in GitHub Desktop.
Typescript Clean Architecture Filtering Layer

Clean Architecture Filtering Layer: Design Patterns and Implementation

Overview

This code sample demonstrates a complete filtering layer implementation that adheres to clean architecture principles. The system provides type-safe, configurable filtering for business entities while maintaining strict separation of concerns and dependency inversion.

Architecture Layers

Domain Layer (Innermost)

The domain layer contains pure business entities and value types with no external dependencies:

export interface User {
  id: string;
  email: string;
  // ... other fields
}

const userRole = ['admin', 'user', 'moderator'] as const;
export type UserRole = (typeof userRole)[number];

Key Characteristics:

  • Entities represent core business concepts
  • Value types use as const assertions for compile-time type safety
  • No framework dependencies or infrastructure concerns

Application Layer (Use Cases)

The application layer orchestrates business logic through service interfaces:

export interface ApiFilterService<T> {
  findEntities(query: FilterQuery<T>): Promise<ApiResponse<T[]>>;
  countEntities(filters: Filter<T>[]): Promise<ApiResponse<number>>;
  // ... CRUD operations
}

Design Decisions:

  • Generic interfaces enable reuse across entity types
  • All operations return wrapped responses with error handling
  • Services depend on abstractions, not concrete implementations

Interface Adapters Layer

This layer contains validation logic and field registry management:

export function createFilterValidationService<T>(
  fieldRegistry: FieldEvaluatorRegistry<T>
): FilterValidationService<T>

Responsibilities:

  • Validate filter operations against field types
  • Convert between external API formats and internal representations
  • Enforce business rules for filter construction

Infrastructure Layer (Outermost)

The repository interface defines the contract for data persistence:

export interface Repository<T> {
  findByFilters(query: FilterQuery<T>): Promise<T[]>;
  // ... other persistence operations
}

Design Patterns Applied

1. Revealing Module Pattern

Implementation:

export function createFieldEvaluatorRegistry<T>(): {
  register: (field: keyof T, metadata: FieldMetadata) => void;
  registry: FieldEvaluatorRegistry<T>;
} {
  const fieldMappings = new Map<keyof T, FieldMetadata>(); // Private state
  
  // Private functions...
  
  return { register, registry }; // Public interface
}

Benefits:

  • Encapsulates internal state within closures
  • Exposes only necessary public methods
  • Eliminates need for class-based inheritance hierarchies
  • Provides clear separation between public API and implementation

Tradeoffs:

  • Slightly more verbose than class-based approach
  • Each instance creates new function references (minor memory overhead)
  • Less familiar to developers coming from OOP backgrounds

2. Type-Level Configuration

Implementation:

const fieldType = ['string', 'number', 'boolean'] as const;
export type FieldType = (typeof fieldType)[number];

export const FIELD_TYPE_OPERATORS: Record<FieldType, FieldTypeConfig> = {
  string: { allowedOperators: stringOperators },
  number: { allowedOperators: numberOperators },
  // ...
};

Benefits:

  • Compile-time validation of field types and operators
  • Eliminates runtime string literal errors
  • Enables IDE autocomplete and refactoring support
  • Self-documenting code through type constraints

Tradeoffs:

  • Requires TypeScript for full benefits
  • Can make types more complex for beginners
  • Build-time overhead for type checking

3. Strategy Pattern via Type System

Implementation:

switch (metadata.type) {
  case 'literal_union':
    return validateLiteralUnionValue(filter, metadata);
  case 'number':
    return validateNumberValue(filter);
  // ... other strategies
}

Benefits:

  • Validation strategies determined by field type
  • Easy to extend with new field types
  • Type-safe dispatch based on discriminated unions
  • Avoids complex inheritance hierarchies

Tradeoffs:

  • Validation logic spread across multiple functions
  • Requires careful coordination between type definitions and implementations

4. Dependency Injection via Factory Functions

Implementation:

export function createUserService(userRepository: Repository<User>): ApiFilterService<User> {
  const validationService = createFilterValidationService(userFieldRegistry);
  return createApiFilterService(userRepository, validationService);
}

Benefits:

  • Clear dependency relationships
  • Easy to test with mock implementations
  • Supports multiple configurations
  • No global state or service locators

Tradeoffs:

  • Manual wiring of dependencies
  • Can become verbose for complex dependency graphs
  • No automatic lifecycle management

Clean Architecture Conformance

Dependency Rule

Dependencies point inward toward higher-level policies:

  • Domain entities have no dependencies
  • Application services depend on domain abstractions
  • Infrastructure implementations depend on application interfaces
  • External frameworks interact only through interface adapters

Stable Dependencies Principle

Core business logic depends on stable abstractions:

  • FilterQuery<T> interface remains stable regardless of storage implementation
  • Field type system provides stable operator mappings
  • Validation rules are independent of external API formats

Interface Segregation

Interfaces are focused and cohesive:

  • Repository<T> contains only data access operations
  • FilterValidationService<T> handles only validation concerns
  • ApiFilterService<T> manages only API-level operations

Extensibility Points

Adding New Field Types

  1. Extend the fieldType const array
  2. Define operator mappings in FIELD_TYPE_OPERATORS
  3. Add validation logic in createFilterValidationService

Adding New Operators

  1. Extend the filterOperator const array
  2. Update relevant operator arrays (e.g., stringOperators)
  3. Implement validation logic for the new operator

Supporting New Entities

  1. Define entity interface in domain layer
  2. Create field registry configuration
  3. Use factory function to create typed service

Testing Strategy

The architecture supports comprehensive testing at each layer:

Unit Tests:

  • Domain entities (pure functions, no dependencies)
  • Validation logic (isolated business rules)
  • Field registry operations (type safety verification)

Integration Tests:

  • API service with mock repository
  • End-to-end filter query processing
  • Cross-layer contract verification

Property-Based Testing:

  • Generate random valid filter queries
  • Verify validation rules against type constraints
  • Test operator behavior across field types

Performance Considerations

Compile-Time Optimizations:

  • Type-level computations reduce runtime checks
  • Const assertions enable dead code elimination
  • Generic specialization allows for optimized implementations

Runtime Characteristics:

  • Field registries use Map for O(1) metadata lookup
  • Validation short-circuits on first error
  • Operator mappings are pre-computed constants

Memory Management:

  • Revealing module pattern creates closures (minor overhead)
  • Field registries share operator arrays via references
  • No class inheritance chains or prototype pollution

Conclusion

This filtering layer demonstrates how functional programming patterns can be effectively combined with TypeScript's type system to create clean, maintainable architecture. The design prioritizes type safety, testability, and extensibility while maintaining clear separation between business logic and infrastructure concerns.

The patterns shown here scale well to complex domain models and provide a solid foundation for building robust query systems that can evolve with changing business requirements.

// === HIGH-LEVEL DOMAIN TYPES ===
// Domain Entities
export interface User {
id: string;
email: string;
name: string;
age: number;
isActive: boolean;
createdAt: Date;
role: UserRole;
tags: string[];
}
export interface Product {
id: string;
name: string;
price: number;
category: string;
inStock: boolean;
tags: string[];
ratings: number[];
createdAt: Date;
status: ProductStatus;
}
// Domain Value Types
const userRole = ['admin', 'user', 'moderator'] as const;
export type UserRole = (typeof userRole)[number];
const productStatus = ['draft', 'published', 'archived'] as const;
export type ProductStatus = (typeof productStatus)[number];
// === FILTER QUERY INTERFACE ===
export interface FilterQuery<T> {
filters: Filter<T>[];
limit?: number;
offset?: number;
sortBy?: keyof T;
sortOrder?: 'asc' | 'desc';
}
export interface Filter<T> {
field: keyof T;
operator: FilterOperator;
value?: any;
}
// === API SERVICE INTERFACE ===
export interface ApiResponse<T> {
success: boolean;
data?: T;
errors?: readonly string[];
}
export interface ApiFilterService<T> {
findEntities(query: FilterQuery<T>): Promise<ApiResponse<T[]>>;
countEntities(filters: Filter<T>[]): Promise<ApiResponse<number>>;
createEntity(data: Omit<T, 'id'>): Promise<ApiResponse<T>>;
updateEntity(id: string, updates: Partial<T>): Promise<ApiResponse<T>>;
deleteEntity(id: string): Promise<ApiResponse<void>>;
}
// === REPOSITORY INTERFACE ===
export interface Repository<T> {
findByFilters(query: FilterQuery<T>): Promise<T[]>;
count(filters: Filter<T>[]): Promise<number>;
create(entity: Omit<T, 'id'>): Promise<T>;
update(id: string, updates: Partial<T>): Promise<T>;
delete(id: string): Promise<void>;
}
// === SERVICE FACTORIES ===
export function createUserService(userRepository: Repository<User>): ApiFilterService<User> {
const validationService = createFilterValidationService(userFieldRegistry);
return createApiFilterService(userRepository, validationService);
}
export function createProductService(productRepository: Repository<Product>): ApiFilterService<Product> {
const validationService = createFilterValidationService(productFieldRegistry);
return createApiFilterService(productRepository, validationService);
}
// === API SERVICE IMPLEMENTATION ===
export function createApiFilterService<T>(
repository: Repository<T>,
validationService: FilterValidationService<T>
): ApiFilterService<T> {
const findEntities = async (query: FilterQuery<T>): Promise<ApiResponse<T[]>> => {
const validation = validationService.validateQuery(query);
if (!validation.isValid) {
return {
success: false,
errors: validation.errors
};
}
try {
const entities = await repository.findByFilters(query);
return {
success: true,
data: entities
};
} catch (error) {
return {
success: false,
errors: ['Internal server error']
};
}
};
const countEntities = async (filters: Filter<T>[]): Promise<ApiResponse<number>> => {
const query: FilterQuery<T> = { filters };
const validation = validationService.validateQuery(query);
if (!validation.isValid) {
return {
success: false,
errors: validation.errors
};
}
try {
const count = await repository.count(filters);
return {
success: true,
data: count
};
} catch (error) {
return {
success: false,
errors: ['Internal server error']
};
}
};
const createEntity = async (data: Omit<T, 'id'>): Promise<ApiResponse<T>> => {
try {
const entity = await repository.create(data);
return {
success: true,
data: entity
};
} catch (error) {
return {
success: false,
errors: ['Failed to create entity']
};
}
};
const updateEntity = async (id: string, updates: Partial<T>): Promise<ApiResponse<T>> => {
try {
const entity = await repository.update(id, updates);
return {
success: true,
data: entity
};
} catch (error) {
return {
success: false,
errors: ['Failed to update entity']
};
}
};
const deleteEntity = async (id: string): Promise<ApiResponse<void>> => {
try {
await repository.delete(id);
return {
success: true
};
} catch (error) {
return {
success: false,
errors: ['Failed to delete entity']
};
}
};
return {
findEntities,
countEntities,
createEntity,
updateEntity,
deleteEntity
};
}
// === VALIDATION SERVICE ===
export interface ValidationResult {
isValid: boolean;
errors: readonly string[];
}
export interface FilterValidationService<T> {
validateQuery(query: FilterQuery<T>): ValidationResult;
}
export function createFilterValidationService<T>(
fieldRegistry: FieldEvaluatorRegistry<T>
): FilterValidationService<T> {
const validateQuery = (query: FilterQuery<T>): ValidationResult => {
const errors: string[] = [];
for (const filter of query.filters) {
if (!fieldRegistry.validateFilter(filter)) {
const metadata = fieldRegistry.getMetadata(filter.field);
if (!metadata) {
errors.push(`Unknown field: ${String(filter.field)}`);
} else {
const allowedOps = fieldRegistry.getAllowedOperators(filter.field);
errors.push(`Invalid operator ${filter.operator} for field ${String(filter.field)}. Allowed: ${allowedOps.join(', ')}`);
}
}
const validationError = validateFilterValue(filter);
if (validationError) {
errors.push(validationError);
}
}
return {
isValid: errors.length === 0,
errors
};
};
const validateFilterValue = (filter: Filter<T>): string | null => {
const metadata = fieldRegistry.getMetadata(filter.field);
if (!metadata) return null;
const noValueOperators: readonly FilterOperator[] = [
'is_null',
'is_not_null',
'array_empty',
'array_not_empty'
];
if (noValueOperators.includes(filter.operator)) {
return null;
}
if (filter.value === undefined || filter.value === null) {
return `Value required for operator ${filter.operator}`;
}
switch (metadata.type) {
case 'literal_union':
return validateLiteralUnionValue(filter, metadata);
case 'number':
return validateNumberValue(filter);
case 'boolean':
return validateBooleanValue(filter);
case 'date':
return validateDateValue(filter);
case 'string_array':
return validateArrayValue(filter, 'string');
case 'number_array':
return validateArrayValue(filter, 'number');
default:
return null;
}
};
const validateLiteralUnionValue = (filter: Filter<T>, metadata: FieldMetadata): string | null => {
if (metadata.allowedValues) {
const inOperators: readonly FilterOperator[] = ['in', 'not_in'];
if (inOperators.includes(filter.operator)) {
if (!Array.isArray(filter.value)) {
return `Expected array for ${filter.operator} operator`;
}
const invalidValues = filter.value.filter(v => !metadata.allowedValues!.includes(v));
if (invalidValues.length > 0) {
return `Invalid values: ${invalidValues.join(', ')}. Allowed: ${metadata.allowedValues.join(', ')}`;
}
} else if (!metadata.allowedValues.includes(filter.value)) {
return `Invalid value: ${filter.value}. Allowed: ${metadata.allowedValues.join(', ')}`;
}
}
return null;
};
const validateNumberValue = (filter: Filter<T>): string | null => {
const arrayOperators: readonly FilterOperator[] = ['in', 'not_in'];
if (arrayOperators.includes(filter.operator)) {
if (!Array.isArray(filter.value) || !filter.value.every(v => typeof v === 'number')) {
return `Expected array of numbers for ${filter.operator} operator`;
}
} else if (typeof filter.value !== 'number') {
return `Expected number for field ${String(filter.field)}`;
}
return null;
};
const validateBooleanValue = (filter: Filter<T>): string | null => {
if (typeof filter.value !== 'boolean') {
return `Expected boolean for field ${String(filter.field)}`;
}
return null;
};
const validateDateValue = (filter: Filter<T>): string | null => {
if (!(filter.value instanceof Date) && typeof filter.value !== 'string') {
return `Expected Date or ISO string for field ${String(filter.field)}`;
}
return null;
};
const validateArrayValue = (filter: Filter<T>, elementType: 'string' | 'number'): string | null => {
const typeCheck = elementType === 'string'
? (v: any) => typeof v === 'string'
: (v: any) => typeof v === 'number';
const lengthOperators: readonly FilterOperator[] = [
'array_length_eq',
'array_length_gt',
'array_length_gte',
'array_length_lt',
'array_length_lte'
];
const singleValueOperators: readonly FilterOperator[] = ['array_contains'];
const multiValueOperators: readonly FilterOperator[] = ['array_contains_any', 'array_contains_all'];
if (lengthOperators.includes(filter.operator)) {
if (typeof filter.value !== 'number') {
return `Expected number for ${filter.operator}`;
}
} else if (singleValueOperators.includes(filter.operator)) {
if (!typeCheck(filter.value)) {
return `Expected ${elementType} value for ${filter.operator}`;
}
} else if (multiValueOperators.includes(filter.operator)) {
if (!Array.isArray(filter.value) || !filter.value.every(typeCheck)) {
return `Expected array of ${elementType}s for ${filter.operator}`;
}
}
return null;
};
return { validateQuery };
}
// === FIELD REGISTRY ===
export interface FieldEvaluatorRegistry<T> {
getMetadata(field: keyof T): FieldMetadata | undefined;
getAllowedOperators(field: keyof T): readonly FilterOperator[];
validateFilter(filter: Filter<T>): boolean;
getAllFields(): readonly (keyof T)[];
getFieldsByType(type: FieldType): readonly (keyof T)[];
}
export function createFieldEvaluatorRegistry<T>(): {
register: (field: keyof T, metadata: FieldMetadata) => void;
registry: FieldEvaluatorRegistry<T>;
} {
const fieldMappings = new Map<keyof T, FieldMetadata>();
const register = (field: keyof T, metadata: FieldMetadata): void => {
fieldMappings.set(field, metadata);
};
const getMetadata = (field: keyof T): FieldMetadata | undefined => {
return fieldMappings.get(field);
};
const getAllowedOperators = (field: keyof T): readonly FilterOperator[] => {
const metadata = getMetadata(field);
if (!metadata) return [];
return FIELD_TYPE_OPERATORS[metadata.type].allowedOperators;
};
const validateFilter = (filter: Filter<T>): boolean => {
const allowedOperators = getAllowedOperators(filter.field);
return allowedOperators.includes(filter.operator);
};
const getAllFields = (): readonly (keyof T)[] => {
return Array.from(fieldMappings.keys());
};
const getFieldsByType = (type: FieldType): readonly (keyof T)[] => {
return Array.from(fieldMappings.entries())
.filter(([_, metadata]) => metadata.type === type)
.map(([field, _]) => field);
};
const registry: FieldEvaluatorRegistry<T> = {
getMetadata,
getAllowedOperators,
validateFilter,
getAllFields,
getFieldsByType
};
return { register, registry };
}
// === FIELD REGISTRIES CONFIGURATION ===
// User Field Registry
const userFieldEvaluator = createFieldEvaluatorRegistry<User>();
userFieldEvaluator.register('id', { type: 'string' });
userFieldEvaluator.register('email', { type: 'string' });
userFieldEvaluator.register('name', { type: 'string' });
userFieldEvaluator.register('age', { type: 'number' });
userFieldEvaluator.register('isActive', { type: 'boolean' });
userFieldEvaluator.register('createdAt', { type: 'date' });
userFieldEvaluator.register('role', { type: 'literal_union', allowedValues: userRole });
userFieldEvaluator.register('tags', { type: 'string_array' });
export const userFieldRegistry = userFieldEvaluator.registry;
// Product Field Registry
const productFieldEvaluator = createFieldEvaluatorRegistry<Product>();
productFieldEvaluator.register('id', { type: 'string' });
productFieldEvaluator.register('name', { type: 'string' });
productFieldEvaluator.register('price', { type: 'number' });
productFieldEvaluator.register('category', { type: 'string' });
productFieldEvaluator.register('inStock', { type: 'boolean' });
productFieldEvaluator.register('tags', { type: 'string_array' });
productFieldEvaluator.register('ratings', { type: 'number_array' });
productFieldEvaluator.register('createdAt', { type: 'date' });
productFieldEvaluator.register('status', { type: 'literal_union', allowedValues: productStatus });
export const productFieldRegistry = productFieldEvaluator.registry;
// === FIELD TYPE SYSTEM ===
export interface FieldMetadata {
type: FieldType;
allowedValues?: readonly string[];
}
export interface FieldTypeConfig {
allowedOperators: readonly FilterOperator[];
}
const fieldType = [
'string',
'number',
'boolean',
'date',
'literal_union',
'string_array',
'number_array'
] as const;
export type FieldType = (typeof fieldType)[number];
const filterOperator = [
'eq',
'ne',
'gt',
'gte',
'lt',
'lte',
'contains',
'starts_with',
'ends_with',
'in',
'not_in',
'is_null',
'is_not_null',
'array_contains',
'array_contains_any',
'array_contains_all',
'array_length_eq',
'array_length_gt',
'array_length_gte',
'array_length_lt',
'array_length_lte',
'array_empty',
'array_not_empty'
] as const;
export type FilterOperator = (typeof filterOperator)[number];
// === OPERATOR MAPPINGS ===
const stringOperators = [
'eq',
'ne',
'contains',
'starts_with',
'ends_with',
'in',
'not_in',
'is_null',
'is_not_null'
] as const;
const numberOperators = [
'eq',
'ne',
'gt',
'gte',
'lt',
'lte',
'in',
'not_in',
'is_null',
'is_not_null'
] as const;
const booleanOperators = [
'eq',
'ne',
'is_null',
'is_not_null'
] as const;
const dateOperators = [
'eq',
'ne',
'gt',
'gte',
'lt',
'lte',
'is_null',
'is_not_null'
] as const;
const literalUnionOperators = [
'eq',
'ne',
'in',
'not_in',
'is_null',
'is_not_null'
] as const;
const arrayOperators = [
'array_contains',
'array_contains_any',
'array_contains_all',
'array_length_eq',
'array_length_gt',
'array_length_gte',
'array_length_lt',
'array_length_lte',
'array_empty',
'array_not_empty',
'is_null',
'is_not_null'
] as const;
export const FIELD_TYPE_OPERATORS: Record<FieldType, FieldTypeConfig> = {
string: { allowedOperators: stringOperators },
number: { allowedOperators: numberOperators },
boolean: { allowedOperators: booleanOperators },
date: { allowedOperators: dateOperators },
literal_union: { allowedOperators: literalUnionOperators },
string_array: { allowedOperators: arrayOperators },
number_array: { allowedOperators: arrayOperators }
};
// === USAGE EXAMPLES ===
const exampleUserQuery: FilterQuery<User> = {
filters: [
{ field: 'isActive', operator: 'eq', value: true },
{ field: 'age', operator: 'gt', value: 18 },
{ field: 'role', operator: 'in', value: ['user', 'moderator'] },
{ field: 'tags', operator: 'array_contains', value: 'premium' }
],
limit: 10,
offset: 0,
sortBy: 'createdAt',
sortOrder: 'desc'
};
const exampleProductQuery: FilterQuery<Product> = {
filters: [
{ field: 'inStock', operator: 'eq', value: true },
{ field: 'price', operator: 'lte', value: 100 },
{ field: 'status', operator: 'eq', value: 'published' },
{ field: 'tags', operator: 'array_contains_any', value: ['sale', 'featured'] },
{ field: 'ratings', operator: 'array_length_gte', value: 5 }
],
sortBy: 'price',
sortOrder: 'asc'
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment