diff --git a/server/src/infra/infra.config.ts b/server/src/config.ts
similarity index 50%
rename from server/src/infra/infra.config.ts
rename to server/src/config.ts
index f521b96b75..01f3f1a1c0 100644
--- a/server/src/infra/infra.config.ts
+++ b/server/src/config.ts
@@ -1,7 +1,42 @@
 import { RegisterQueueOptions } from '@nestjs/bullmq';
+import { ConfigModuleOptions } from '@nestjs/config';
 import { QueueOptions } from 'bullmq';
 import { RedisOptions } from 'ioredis';
+import Joi from 'joi';
 import { QueueName } from 'src/domain/job/job.constants';
+import { LogLevel } from 'src/infra/entities/system-config.entity';
+
+const WHEN_DB_URL_SET = Joi.when('DB_URL', {
+  is: Joi.exist(),
+  then: Joi.string().optional(),
+  otherwise: Joi.string().required(),
+});
+
+export const immichAppConfig: ConfigModuleOptions = {
+  envFilePath: '.env',
+  isGlobal: true,
+  validationSchema: Joi.object({
+    NODE_ENV: Joi.string().optional().valid('development', 'production', 'staging').default('development'),
+    LOG_LEVEL: Joi.string()
+      .optional()
+      .valid(...Object.values(LogLevel)),
+
+    DB_USERNAME: WHEN_DB_URL_SET,
+    DB_PASSWORD: WHEN_DB_URL_SET,
+    DB_DATABASE_NAME: WHEN_DB_URL_SET,
+    DB_URL: Joi.string().optional(),
+    DB_VECTOR_EXTENSION: Joi.string().optional().valid('pgvector', 'pgvecto.rs').default('pgvecto.rs'),
+
+    MACHINE_LEARNING_PORT: Joi.number().optional(),
+    MICROSERVICES_PORT: Joi.number().optional(),
+    IMMICH_METRICS_PORT: Joi.number().optional(),
+
+    IMMICH_METRICS: Joi.boolean().optional().default(false),
+    IMMICH_HOST_METRICS: Joi.boolean().optional().default(false),
+    IMMICH_API_METRICS: Joi.boolean().optional().default(false),
+    IMMICH_IO_METRICS: Joi.boolean().optional().default(false),
+  }),
+};
 
 function parseRedisConfig(): RedisOptions {
   const redisUrl = process.env.REDIS_URL;
diff --git a/server/src/decorators.ts b/server/src/decorators.ts
new file mode 100644
index 0000000000..06dc0bfdcc
--- /dev/null
+++ b/server/src/decorators.ts
@@ -0,0 +1,124 @@
+import { SetMetadata } from '@nestjs/common';
+import _ from 'lodash';
+import { setUnion } from 'src/utils';
+
+// PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the
+// maximum number of parameters is 65535. Any query that tries to bind more than that (e.g. searching
+// by a list of IDs) requires splitting the query into multiple chunks.
+// We are rounding down this limit, as queries commonly include other filters and parameters.
+export const DATABASE_PARAMETER_CHUNK_SIZE = 65_500;
+
+/**
+ * Chunks an array or set into smaller collections of the same type and specified size.
+ *
+ * @param collection The collection to chunk.
+ * @param size The size of each chunk.
+ */
+function chunks<T>(collection: Array<T>, size: number): Array<Array<T>>;
+function chunks<T>(collection: Set<T>, size: number): Array<Set<T>>;
+function chunks<T>(collection: Array<T> | Set<T>, size: number): Array<Array<T>> | Array<Set<T>> {
+  if (collection instanceof Set) {
+    const result = [];
+    let chunk = new Set<T>();
+    for (const element of collection) {
+      chunk.add(element);
+      if (chunk.size === size) {
+        result.push(chunk);
+        chunk = new Set<T>();
+      }
+    }
+    if (chunk.size > 0) {
+      result.push(chunk);
+    }
+    return result;
+  } else {
+    return _.chunk(collection, size);
+  }
+}
+
+/**
+ * Wraps a method that takes a collection of parameters and sequentially calls it with chunks of the collection,
+ * to overcome the maximum number of parameters allowed by the database driver.
+ *
+ * @param options.paramIndex The index of the function parameter to chunk. Defaults to 0.
+ * @param options.flatten Whether to flatten the results. Defaults to false.
+ */
+export function Chunked(options: { paramIndex?: number; mergeFn?: (results: any) => any } = {}): MethodDecorator {
+  return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
+    const originalMethod = descriptor.value;
+    const parameterIndex = options.paramIndex ?? 0;
+    descriptor.value = async function (...arguments_: any[]) {
+      const argument = arguments_[parameterIndex];
+
+      // Early return if argument length is less than or equal to the chunk size.
+      if (
+        (Array.isArray(argument) && argument.length <= DATABASE_PARAMETER_CHUNK_SIZE) ||
+        (argument instanceof Set && argument.size <= DATABASE_PARAMETER_CHUNK_SIZE)
+      ) {
+        return await originalMethod.apply(this, arguments_);
+      }
+
+      return Promise.all(
+        chunks(argument, DATABASE_PARAMETER_CHUNK_SIZE).map(async (chunk) => {
+          await Reflect.apply(originalMethod, this, [
+            ...arguments_.slice(0, parameterIndex),
+            chunk,
+            ...arguments_.slice(parameterIndex + 1),
+          ]);
+        }),
+      ).then((results) => (options.mergeFn ? options.mergeFn(results) : results));
+    };
+  };
+}
+
+export function ChunkedArray(options?: { paramIndex?: number }): MethodDecorator {
+  return Chunked({ ...options, mergeFn: _.flatten });
+}
+
+export function ChunkedSet(options?: { paramIndex?: number }): MethodDecorator {
+  return Chunked({ ...options, mergeFn: setUnion });
+}
+
+// https://stackoverflow.com/a/74898678
+export function DecorateAll(
+  decorator: <T>(
+    target: any,
+    propertyKey: string,
+    descriptor: TypedPropertyDescriptor<T>,
+  ) => TypedPropertyDescriptor<T> | void,
+) {
+  return (target: any) => {
+    const descriptors = Object.getOwnPropertyDescriptors(target.prototype);
+    for (const [propName, descriptor] of Object.entries(descriptors)) {
+      const isMethod = typeof descriptor.value == 'function' && propName !== 'constructor';
+      if (!isMethod) {
+        continue;
+      }
+      decorator({ ...target, constructor: { ...target.constructor, name: target.name } as any }, propName, descriptor);
+      Object.defineProperty(target.prototype, propName, descriptor);
+    }
+  };
+}
+
+const UUID = '00000000-0000-4000-a000-000000000000';
+
+export const DummyValue = {
+  UUID,
+  UUID_SET: new Set([UUID]),
+  PAGINATION: { take: 10, skip: 0 },
+  EMAIL: 'user@immich.app',
+  STRING: 'abcdefghi',
+  BUFFER: Buffer.from('abcdefghi'),
+  DATE: new Date(),
+  TIME_BUCKET: '2024-01-01T00:00:00.000Z',
+};
+
+export const GENERATE_SQL_KEY = 'generate-sql-key';
+
+export interface GenerateSqlQueries {
+  name?: string;
+  params: unknown[];
+}
+
+/** Decorator to enable versioning/tracking of generated Sql */
+export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GENERATE_SQL_KEY, options);
diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts
index a514128568..eb4ff55d12 100644
--- a/server/src/domain/access/access.core.ts
+++ b/server/src/domain/access/access.core.ts
@@ -1,8 +1,8 @@
 import { BadRequestException, UnauthorizedException } from '@nestjs/common';
 import { AuthDto } from 'src/domain/auth/auth.dto';
-import { setDifference, setIsEqual, setUnion } from 'src/domain/domain.util';
 import { IAccessRepository } from 'src/domain/repositories/access.repository';
 import { SharedLinkEntity } from 'src/infra/entities/shared-link.entity';
+import { setDifference, setIsEqual, setUnion } from 'src/utils';
 
 export enum Permission {
   ACTIVITY_CREATE = 'activity.create',
diff --git a/server/src/domain/activity/activity.dto.ts b/server/src/domain/activity/activity.dto.ts
index 627a06c42e..1bfbabd806 100644
--- a/server/src/domain/activity/activity.dto.ts
+++ b/server/src/domain/activity/activity.dto.ts
@@ -1,8 +1,8 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { IsEnum, IsNotEmpty, IsString, ValidateIf } from 'class-validator';
-import { Optional, ValidateUUID } from 'src/domain/domain.util';
 import { UserDto, mapSimpleUser } from 'src/domain/user/response-dto/user-response.dto';
 import { ActivityEntity } from 'src/infra/entities/activity.entity';
+import { Optional, ValidateUUID } from 'src/validation';
 
 export enum ReactionType {
   COMMENT = 'comment',
diff --git a/server/src/domain/album/album-response.dto.ts b/server/src/domain/album/album-response.dto.ts
index ab8454a493..663a463f2c 100644
--- a/server/src/domain/album/album-response.dto.ts
+++ b/server/src/domain/album/album-response.dto.ts
@@ -1,9 +1,9 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { AssetResponseDto, mapAsset } from 'src/domain/asset/response-dto/asset-response.dto';
 import { AuthDto } from 'src/domain/auth/auth.dto';
-import { Optional } from 'src/domain/domain.util';
 import { UserResponseDto, mapUser } from 'src/domain/user/response-dto/user-response.dto';
 import { AlbumEntity, AssetOrder } from 'src/infra/entities/album.entity';
+import { Optional } from 'src/validation';
 
 export class AlbumResponseDto {
   id!: string;
diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts
index 7ba91f1d9c..d54680fe42 100644
--- a/server/src/domain/album/album.service.ts
+++ b/server/src/domain/album/album.service.ts
@@ -14,7 +14,6 @@ import { AlbumInfoDto } from 'src/domain/album/dto/album.dto';
 import { GetAlbumsDto } from 'src/domain/album/dto/get-albums.dto';
 import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/domain/asset/response-dto/asset-ids-response.dto';
 import { AuthDto } from 'src/domain/auth/auth.dto';
-import { setUnion } from 'src/domain/domain.util';
 import { IAccessRepository } from 'src/domain/repositories/access.repository';
 import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/domain/repositories/album.repository';
 import { IAssetRepository } from 'src/domain/repositories/asset.repository';
@@ -22,6 +21,7 @@ import { IUserRepository } from 'src/domain/repositories/user.repository';
 import { AlbumEntity } from 'src/infra/entities/album.entity';
 import { AssetEntity } from 'src/infra/entities/asset.entity';
 import { UserEntity } from 'src/infra/entities/user.entity';
+import { setUnion } from 'src/utils';
 
 @Injectable()
 export class AlbumService {
diff --git a/server/src/domain/album/dto/album-add-users.dto.ts b/server/src/domain/album/dto/album-add-users.dto.ts
index b6a9e8be9f..1a6be48232 100644
--- a/server/src/domain/album/dto/album-add-users.dto.ts
+++ b/server/src/domain/album/dto/album-add-users.dto.ts
@@ -1,5 +1,5 @@
 import { ArrayNotEmpty } from 'class-validator';
-import { ValidateUUID } from 'src/domain/domain.util';
+import { ValidateUUID } from 'src/validation';
 
 export class AddUsersDto {
   @ValidateUUID({ each: true })
diff --git a/server/src/domain/album/dto/album-create.dto.ts b/server/src/domain/album/dto/album-create.dto.ts
index 0765e4d77e..1b4a75332e 100644
--- a/server/src/domain/album/dto/album-create.dto.ts
+++ b/server/src/domain/album/dto/album-create.dto.ts
@@ -1,6 +1,6 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { IsString } from 'class-validator';
-import { Optional, ValidateUUID } from 'src/domain/domain.util';
+import { Optional, ValidateUUID } from 'src/validation';
 
 export class CreateAlbumDto {
   @IsString()
diff --git a/server/src/domain/album/dto/album-update.dto.ts b/server/src/domain/album/dto/album-update.dto.ts
index c6b21e08d0..1f329cb3c5 100644
--- a/server/src/domain/album/dto/album-update.dto.ts
+++ b/server/src/domain/album/dto/album-update.dto.ts
@@ -1,7 +1,7 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { IsEnum, IsString } from 'class-validator';
-import { Optional, ValidateBoolean, ValidateUUID } from 'src/domain/domain.util';
 import { AssetOrder } from 'src/infra/entities/album.entity';
+import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
 
 export class UpdateAlbumDto {
   @Optional()
diff --git a/server/src/domain/album/dto/album.dto.ts b/server/src/domain/album/dto/album.dto.ts
index f1772b4c2e..fe0eb0d5cf 100644
--- a/server/src/domain/album/dto/album.dto.ts
+++ b/server/src/domain/album/dto/album.dto.ts
@@ -1,4 +1,4 @@
-import { ValidateBoolean } from 'src/domain/domain.util';
+import { ValidateBoolean } from 'src/validation';
 
 export class AlbumInfoDto {
   @ValidateBoolean({ optional: true })
diff --git a/server/src/domain/album/dto/get-albums.dto.ts b/server/src/domain/album/dto/get-albums.dto.ts
index e03c219aa4..15e4f1cf23 100644
--- a/server/src/domain/album/dto/get-albums.dto.ts
+++ b/server/src/domain/album/dto/get-albums.dto.ts
@@ -1,4 +1,4 @@
-import { ValidateBoolean, ValidateUUID } from 'src/domain/domain.util';
+import { ValidateBoolean, ValidateUUID } from 'src/validation';
 
 export class GetAlbumsDto {
   @ValidateBoolean({ optional: true })
diff --git a/server/src/domain/api-key/api-key.dto.ts b/server/src/domain/api-key/api-key.dto.ts
index 19099ba18c..1f4f855216 100644
--- a/server/src/domain/api-key/api-key.dto.ts
+++ b/server/src/domain/api-key/api-key.dto.ts
@@ -1,5 +1,5 @@
 import { IsNotEmpty, IsString } from 'class-validator';
-import { Optional } from 'src/domain/domain.util';
+import { Optional } from 'src/validation';
 export class APIKeyCreateDto {
   @IsString()
   @IsNotEmpty()
diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts
index 3d78c8c7be..2d514a9371 100644
--- a/server/src/domain/asset/asset.service.ts
+++ b/server/src/domain/asset/asset.service.ts
@@ -21,7 +21,6 @@ import { MapMarkerResponseDto } from 'src/domain/asset/response-dto/map-marker-r
 import { TimeBucketResponseDto } from 'src/domain/asset/response-dto/time-bucket-response.dto';
 import { AuthDto } from 'src/domain/auth/auth.dto';
 import { mimeTypes } from 'src/domain/domain.constant';
-import { usePagination } from 'src/domain/domain.util';
 import { JOBS_ASSET_PAGINATION_SIZE, JobName } from 'src/domain/job/job.constants';
 import { IAssetDeletionJob, ISidecarWriteJob } from 'src/domain/job/job.interface';
 import { IAccessRepository } from 'src/domain/repositories/access.repository';
@@ -38,6 +37,7 @@ import { SystemConfigCore } from 'src/domain/system-config/system-config.core';
 import { AssetEntity } from 'src/infra/entities/asset.entity';
 import { LibraryType } from 'src/infra/entities/library.entity';
 import { ImmichLogger } from 'src/infra/logger';
+import { usePagination } from 'src/utils';
 
 export enum UploadFieldName {
   ASSET_DATA = 'assetData',
diff --git a/server/src/domain/asset/dto/asset-ids.dto.ts b/server/src/domain/asset/dto/asset-ids.dto.ts
index ae236f13db..ea875e85e5 100644
--- a/server/src/domain/asset/dto/asset-ids.dto.ts
+++ b/server/src/domain/asset/dto/asset-ids.dto.ts
@@ -1,6 +1,6 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { IsEnum } from 'class-validator';
-import { ValidateUUID } from 'src/domain/domain.util';
+import { ValidateUUID } from 'src/validation';
 
 export class AssetIdsDto {
   @ValidateUUID({ each: true })
diff --git a/server/src/domain/asset/dto/asset-stack.dto.ts b/server/src/domain/asset/dto/asset-stack.dto.ts
index e05bf6a0b2..3ff04ee5ed 100644
--- a/server/src/domain/asset/dto/asset-stack.dto.ts
+++ b/server/src/domain/asset/dto/asset-stack.dto.ts
@@ -1,4 +1,4 @@
-import { ValidateUUID } from 'src/domain/domain.util';
+import { ValidateUUID } from 'src/validation';
 
 export class UpdateStackParentDto {
   @ValidateUUID()
diff --git a/server/src/domain/asset/dto/asset-statistics.dto.ts b/server/src/domain/asset/dto/asset-statistics.dto.ts
index 9d935789ac..7694955c27 100644
--- a/server/src/domain/asset/dto/asset-statistics.dto.ts
+++ b/server/src/domain/asset/dto/asset-statistics.dto.ts
@@ -1,7 +1,7 @@
 import { ApiProperty } from '@nestjs/swagger';
-import { ValidateBoolean } from 'src/domain/domain.util';
 import { AssetStats } from 'src/domain/repositories/asset.repository';
 import { AssetType } from 'src/infra/entities/asset.entity';
+import { ValidateBoolean } from 'src/validation';
 
 export class AssetStatsDto {
   @ValidateBoolean({ optional: true })
diff --git a/server/src/domain/asset/dto/asset.dto.ts b/server/src/domain/asset/dto/asset.dto.ts
index 31eaa916e8..a93a59ae3b 100644
--- a/server/src/domain/asset/dto/asset.dto.ts
+++ b/server/src/domain/asset/dto/asset.dto.ts
@@ -10,7 +10,7 @@ import {
   ValidateIf,
 } from 'class-validator';
 import { BulkIdsDto } from 'src/domain/asset/response-dto/asset-ids-response.dto';
-import { Optional, ValidateBoolean, ValidateUUID } from 'src/domain/domain.util';
+import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
 
 export class DeviceIdDto {
   @IsNotEmpty()
diff --git a/server/src/domain/asset/dto/map-marker.dto.ts b/server/src/domain/asset/dto/map-marker.dto.ts
index f06dc201e2..158750e516 100644
--- a/server/src/domain/asset/dto/map-marker.dto.ts
+++ b/server/src/domain/asset/dto/map-marker.dto.ts
@@ -1,4 +1,4 @@
-import { ValidateBoolean, ValidateDate } from 'src/domain/domain.util';
+import { ValidateBoolean, ValidateDate } from 'src/validation';
 
 export class MapMarkerDto {
   @ValidateBoolean({ optional: true })
diff --git a/server/src/domain/asset/dto/time-bucket.dto.ts b/server/src/domain/asset/dto/time-bucket.dto.ts
index 052e3732e2..c9e104256a 100644
--- a/server/src/domain/asset/dto/time-bucket.dto.ts
+++ b/server/src/domain/asset/dto/time-bucket.dto.ts
@@ -1,8 +1,8 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
-import { Optional, ValidateBoolean, ValidateUUID } from 'src/domain/domain.util';
 import { TimeBucketSize } from 'src/domain/repositories/asset.repository';
 import { AssetOrder } from 'src/infra/entities/album.entity';
+import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
 
 export class TimeBucketDto {
   @IsNotEmpty()
diff --git a/server/src/domain/asset/response-dto/asset-ids-response.dto.ts b/server/src/domain/asset/response-dto/asset-ids-response.dto.ts
index 3a7e8129a4..fdc9942e37 100644
--- a/server/src/domain/asset/response-dto/asset-ids-response.dto.ts
+++ b/server/src/domain/asset/response-dto/asset-ids-response.dto.ts
@@ -1,4 +1,4 @@
-import { ValidateUUID } from 'src/domain/domain.util';
+import { ValidateUUID } from 'src/validation';
 
 /** @deprecated Use `BulkIdResponseDto` instead */
 export enum AssetIdErrorReason {
diff --git a/server/src/domain/audit/audit.dto.ts b/server/src/domain/audit/audit.dto.ts
index 131ce147b2..1c34e94916 100644
--- a/server/src/domain/audit/audit.dto.ts
+++ b/server/src/domain/audit/audit.dto.ts
@@ -1,9 +1,9 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { Type } from 'class-transformer';
 import { IsArray, IsEnum, IsString, IsUUID, ValidateNested } from 'class-validator';
-import { Optional, ValidateDate, ValidateUUID } from 'src/domain/domain.util';
 import { EntityType } from 'src/infra/entities/audit.entity';
 import { AssetPathType, PathType, PersonPathType, UserPathType } from 'src/infra/entities/move.entity';
+import { Optional, ValidateDate, ValidateUUID } from 'src/validation';
 
 const PathEnum = Object.values({ ...AssetPathType, ...PersonPathType, ...UserPathType });
 
diff --git a/server/src/domain/audit/audit.service.ts b/server/src/domain/audit/audit.service.ts
index 0bcb2ed15d..c72d1d8cf1 100644
--- a/server/src/domain/audit/audit.service.ts
+++ b/server/src/domain/audit/audit.service.ts
@@ -12,7 +12,6 @@ import {
 } from 'src/domain/audit/audit.dto';
 import { AuthDto } from 'src/domain/auth/auth.dto';
 import { AUDIT_LOG_MAX_DURATION } from 'src/domain/domain.constant';
-import { usePagination } from 'src/domain/domain.util';
 import { JOBS_ASSET_PAGINATION_SIZE } from 'src/domain/job/job.constants';
 import { IAccessRepository } from 'src/domain/repositories/access.repository';
 import { IAssetRepository } from 'src/domain/repositories/asset.repository';
@@ -26,6 +25,7 @@ import { StorageCore, StorageFolder } from 'src/domain/storage/storage.core';
 import { DatabaseAction } from 'src/infra/entities/audit.entity';
 import { AssetPathType, PersonPathType, UserPathType } from 'src/infra/entities/move.entity';
 import { ImmichLogger } from 'src/infra/logger';
+import { usePagination } from 'src/utils';
 
 @Injectable()
 export class AuditService {
diff --git a/server/src/domain/auth/auth.service.ts b/server/src/domain/auth/auth.service.ts
index bb01e1b3b9..e66a6d0d70 100644
--- a/server/src/domain/auth/auth.service.ts
+++ b/server/src/domain/auth/auth.service.ts
@@ -34,7 +34,6 @@ import {
   mapLoginResponse,
   mapUserToken,
 } from 'src/domain/auth/auth.dto';
-import { HumanReadableSize } from 'src/domain/domain.util';
 import { IAccessRepository } from 'src/domain/repositories/access.repository';
 import { IKeyRepository } from 'src/domain/repositories/api-key.repository';
 import { ICryptoRepository } from 'src/domain/repositories/crypto.repository';
@@ -49,6 +48,7 @@ import { UserCore } from 'src/domain/user/user.core';
 import { SystemConfig } from 'src/infra/entities/system-config.entity';
 import { UserEntity } from 'src/infra/entities/user.entity';
 import { ImmichLogger } from 'src/infra/logger';
+import { HumanReadableSize } from 'src/utils';
 
 export interface LoginDetails {
   isSecure: boolean;
diff --git a/server/src/domain/domain.config.ts b/server/src/domain/domain.config.ts
deleted file mode 100644
index 40580b8e4c..0000000000
--- a/server/src/domain/domain.config.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-// TODO: remove nestjs references from domain
-import { ConfigModuleOptions } from '@nestjs/config';
-import Joi from 'joi';
-import { LogLevel } from 'src/infra/entities/system-config.entity';
-
-const WHEN_DB_URL_SET = Joi.when('DB_URL', {
-  is: Joi.exist(),
-  then: Joi.string().optional(),
-  otherwise: Joi.string().required(),
-});
-
-export const immichAppConfig: ConfigModuleOptions = {
-  envFilePath: '.env',
-  isGlobal: true,
-  validationSchema: Joi.object({
-    NODE_ENV: Joi.string().optional().valid('development', 'production', 'staging').default('development'),
-    LOG_LEVEL: Joi.string()
-      .optional()
-      .valid(...Object.values(LogLevel)),
-
-    DB_USERNAME: WHEN_DB_URL_SET,
-    DB_PASSWORD: WHEN_DB_URL_SET,
-    DB_DATABASE_NAME: WHEN_DB_URL_SET,
-    DB_URL: Joi.string().optional(),
-    DB_VECTOR_EXTENSION: Joi.string().optional().valid('pgvector', 'pgvecto.rs').default('pgvecto.rs'),
-
-    MACHINE_LEARNING_PORT: Joi.number().optional(),
-    MICROSERVICES_PORT: Joi.number().optional(),
-    IMMICH_METRICS_PORT: Joi.number().optional(),
-
-    IMMICH_METRICS: Joi.boolean().optional().default(false),
-    IMMICH_HOST_METRICS: Joi.boolean().optional().default(false),
-    IMMICH_API_METRICS: Joi.boolean().optional().default(false),
-    IMMICH_IO_METRICS: Joi.boolean().optional().default(false),
-  }),
-};
diff --git a/server/src/domain/domain.util.ts b/server/src/domain/domain.util.ts
deleted file mode 100644
index 820c17f3f0..0000000000
--- a/server/src/domain/domain.util.ts
+++ /dev/null
@@ -1,280 +0,0 @@
-import { BadRequestException, applyDecorators } from '@nestjs/common';
-import { ApiProperty } from '@nestjs/swagger';
-import { Transform } from 'class-transformer';
-import {
-  IsArray,
-  IsBoolean,
-  IsDate,
-  IsNotEmpty,
-  IsOptional,
-  IsString,
-  IsUUID,
-  ValidateIf,
-  ValidationOptions,
-  isDateString,
-} from 'class-validator';
-import { CronJob } from 'cron';
-import _ from 'lodash';
-import { basename, extname } from 'node:path';
-import sanitize from 'sanitize-filename';
-import { ImmichLogger } from 'src/infra/logger';
-
-export enum CacheControl {
-  PRIVATE_WITH_CACHE = 'private_with_cache',
-  PRIVATE_WITHOUT_CACHE = 'private_without_cache',
-  NONE = 'none',
-}
-
-export class ImmichFileResponse {
-  public readonly path!: string;
-  public readonly contentType!: string;
-  public readonly cacheControl!: CacheControl;
-
-  constructor(response: ImmichFileResponse) {
-    Object.assign(this, response);
-  }
-}
-
-export interface OpenGraphTags {
-  title: string;
-  description: string;
-  imageUrl?: string;
-}
-
-export const isConnectionAborted = (error: Error | any) => error.code === 'ECONNABORTED';
-
-type UUIDOptions = { optional?: boolean; each?: boolean };
-export const ValidateUUID = (options?: UUIDOptions) => {
-  const { optional, each } = { optional: false, each: false, ...options };
-  return applyDecorators(
-    IsUUID('4', { each }),
-    ApiProperty({ format: 'uuid' }),
-    optional ? Optional() : IsNotEmpty(),
-    each ? IsArray() : IsString(),
-  );
-};
-
-type DateOptions = { optional?: boolean; nullable?: boolean; format?: 'date' | 'date-time' };
-export const ValidateDate = (options?: DateOptions) => {
-  const { optional, nullable, format } = { optional: false, nullable: false, format: 'date-time', ...options };
-
-  const decorators = [
-    ApiProperty({ format }),
-    IsDate(),
-    optional ? Optional({ nullable: true }) : IsNotEmpty(),
-    Transform(({ key, value }) => {
-      if (value === null || value === undefined) {
-        return value;
-      }
-
-      if (!isDateString(value)) {
-        throw new BadRequestException(`${key} must be a date string`);
-      }
-
-      return new Date(value as string);
-    }),
-  ];
-
-  if (optional) {
-    decorators.push(Optional({ nullable }));
-  }
-
-  return applyDecorators(...decorators);
-};
-
-type BooleanOptions = { optional?: boolean };
-export const ValidateBoolean = (options?: BooleanOptions) => {
-  const { optional } = { optional: false, ...options };
-  const decorators = [
-    // ApiProperty(),
-    IsBoolean(),
-    Transform(({ value }) => {
-      if (value == 'true') {
-        return true;
-      } else if (value == 'false') {
-        return false;
-      }
-      return value;
-    }),
-  ];
-
-  if (optional) {
-    decorators.push(Optional());
-  }
-
-  return applyDecorators(...decorators);
-};
-
-export function validateCronExpression(expression: string) {
-  try {
-    new CronJob(expression, () => {});
-  } catch {
-    return false;
-  }
-
-  return true;
-}
-
-type IValue = { value: string };
-
-export const toEmail = ({ value }: IValue) => value?.toLowerCase();
-
-export const toSanitized = ({ value }: IValue) => sanitize((value || '').replaceAll('.', ''));
-
-export function getFileNameWithoutExtension(path: string): string {
-  return basename(path, extname(path));
-}
-
-export function getLivePhotoMotionFilename(stillName: string, motionName: string) {
-  return getFileNameWithoutExtension(stillName) + extname(motionName);
-}
-
-const KiB = Math.pow(1024, 1);
-const MiB = Math.pow(1024, 2);
-const GiB = Math.pow(1024, 3);
-const TiB = Math.pow(1024, 4);
-const PiB = Math.pow(1024, 5);
-
-export const HumanReadableSize = { KiB, MiB, GiB, TiB, PiB };
-
-export function asHumanReadable(bytes: number, precision = 1): string {
-  const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
-
-  let magnitude = 0;
-  let remainder = bytes;
-  while (remainder >= 1024) {
-    if (magnitude + 1 < units.length) {
-      magnitude++;
-      remainder /= 1024;
-    } else {
-      break;
-    }
-  }
-
-  return `${remainder.toFixed(magnitude == 0 ? 0 : precision)} ${units[magnitude]}`;
-}
-
-export interface PaginationOptions {
-  take: number;
-  skip?: number;
-}
-
-export enum PaginationMode {
-  LIMIT_OFFSET = 'limit-offset',
-  SKIP_TAKE = 'skip-take',
-}
-
-export interface PaginatedBuilderOptions {
-  take: number;
-  skip?: number;
-  mode?: PaginationMode;
-}
-
-export interface PaginationResult<T> {
-  items: T[];
-  hasNextPage: boolean;
-}
-
-export type Paginated<T> = Promise<PaginationResult<T>>;
-
-export async function* usePagination<T>(
-  pageSize: number,
-  getNextPage: (pagination: PaginationOptions) => PaginationResult<T> | Paginated<T>,
-) {
-  let hasNextPage = true;
-
-  for (let skip = 0; hasNextPage; skip += pageSize) {
-    const result = await getNextPage({ take: pageSize, skip });
-    hasNextPage = result.hasNextPage;
-    yield result.items;
-  }
-}
-
-export interface OptionalOptions extends ValidationOptions {
-  nullable?: boolean;
-}
-
-/**
- * Checks if value is missing and if so, ignores all validators.
- *
- * @param validationOptions {@link OptionalOptions}
- *
- * @see IsOptional exported from `class-validator.
- */
-// https://stackoverflow.com/a/71353929
-export function Optional({ nullable, ...validationOptions }: OptionalOptions = {}) {
-  if (nullable === true) {
-    return IsOptional(validationOptions);
-  }
-
-  return ValidateIf((object: any, v: any) => v !== undefined, validationOptions);
-}
-
-/**
- * Chunks an array or set into smaller collections of the same type and specified size.
- *
- * @param collection The collection to chunk.
- * @param size The size of each chunk.
- */
-export function chunks<T>(collection: Array<T>, size: number): Array<Array<T>>;
-export function chunks<T>(collection: Set<T>, size: number): Array<Set<T>>;
-export function chunks<T>(collection: Array<T> | Set<T>, size: number): Array<Array<T>> | Array<Set<T>> {
-  if (collection instanceof Set) {
-    const result = [];
-    let chunk = new Set<T>();
-    for (const element of collection) {
-      chunk.add(element);
-      if (chunk.size === size) {
-        result.push(chunk);
-        chunk = new Set<T>();
-      }
-    }
-    if (chunk.size > 0) {
-      result.push(chunk);
-    }
-    return result;
-  } else {
-    return _.chunk(collection, size);
-  }
-}
-
-// NOTE: The following Set utils have been added here, to easily determine where they are used.
-//       They should be replaced with native Set operations, when they are added to the language.
-//       Proposal reference: https://github.com/tc39/proposal-set-methods
-
-export const setUnion = <T>(...sets: Set<T>[]): Set<T> => {
-  const union = new Set(sets[0]);
-  for (const set of sets.slice(1)) {
-    for (const element of set) {
-      union.add(element);
-    }
-  }
-  return union;
-};
-
-export const setDifference = <T>(setA: Set<T>, ...sets: Set<T>[]): Set<T> => {
-  const difference = new Set(setA);
-  for (const set of sets) {
-    for (const element of set) {
-      difference.delete(element);
-    }
-  }
-  return difference;
-};
-
-export const setIsSuperset = <T>(set: Set<T>, subset: Set<T>): boolean => {
-  for (const element of subset) {
-    if (!set.has(element)) {
-      return false;
-    }
-  }
-  return true;
-};
-
-export const setIsEqual = <T>(setA: Set<T>, setB: Set<T>): boolean => {
-  return setA.size === setB.size && setIsSuperset(setA, setB);
-};
-
-export const handlePromiseError = <T>(promise: Promise<T>, logger: ImmichLogger): void => {
-  promise.catch((error: Error | any) => logger.error(`Promise error: ${error}`, error?.stack));
-};
diff --git a/server/src/domain/download/download.dto.ts b/server/src/domain/download/download.dto.ts
index e350d66acf..e6588a9944 100644
--- a/server/src/domain/download/download.dto.ts
+++ b/server/src/domain/download/download.dto.ts
@@ -1,6 +1,6 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { IsInt, IsPositive } from 'class-validator';
-import { Optional, ValidateUUID } from 'src/domain/domain.util';
+import { Optional, ValidateUUID } from 'src/validation';
 
 export class DownloadInfoDto {
   @ValidateUUID({ each: true, optional: true })
diff --git a/server/src/domain/download/download.service.spec.ts b/server/src/domain/download/download.service.spec.ts
index d22ce69e8d..7c3ecbb026 100644
--- a/server/src/domain/download/download.service.spec.ts
+++ b/server/src/domain/download/download.service.spec.ts
@@ -1,10 +1,10 @@
 import { BadRequestException } from '@nestjs/common';
 import { when } from 'jest-when';
-import { CacheControl, ImmichFileResponse } from 'src/domain/domain.util';
 import { DownloadResponseDto } from 'src/domain/download/download.dto';
 import { DownloadService } from 'src/domain/download/download.service';
 import { IAssetRepository } from 'src/domain/repositories/asset.repository';
 import { IStorageRepository } from 'src/domain/repositories/storage.repository';
+import { CacheControl, ImmichFileResponse } from 'src/utils';
 import { assetStub } from 'test/fixtures/asset.stub';
 import { authStub } from 'test/fixtures/auth.stub';
 import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
diff --git a/server/src/domain/download/download.service.ts b/server/src/domain/download/download.service.ts
index b069ac9b30..13f513c9be 100644
--- a/server/src/domain/download/download.service.ts
+++ b/server/src/domain/download/download.service.ts
@@ -4,12 +4,12 @@ import { AccessCore, Permission } from 'src/domain/access/access.core';
 import { AssetIdsDto } from 'src/domain/asset/dto/asset-ids.dto';
 import { AuthDto } from 'src/domain/auth/auth.dto';
 import { mimeTypes } from 'src/domain/domain.constant';
-import { CacheControl, HumanReadableSize, ImmichFileResponse, usePagination } from 'src/domain/domain.util';
 import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/domain/download/download.dto';
 import { IAccessRepository } from 'src/domain/repositories/access.repository';
 import { IAssetRepository } from 'src/domain/repositories/asset.repository';
 import { IStorageRepository, ImmichReadStream } from 'src/domain/repositories/storage.repository';
 import { AssetEntity } from 'src/infra/entities/asset.entity';
+import { CacheControl, HumanReadableSize, ImmichFileResponse, usePagination } from 'src/utils';
 
 @Injectable()
 export class DownloadService {
diff --git a/server/src/domain/job/job.dto.ts b/server/src/domain/job/job.dto.ts
index f551ab9613..fd463a9b05 100644
--- a/server/src/domain/job/job.dto.ts
+++ b/server/src/domain/job/job.dto.ts
@@ -1,7 +1,7 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { IsEnum, IsNotEmpty } from 'class-validator';
-import { ValidateBoolean } from 'src/domain/domain.util';
 import { JobCommand, QueueName } from 'src/domain/job/job.constants';
+import { ValidateBoolean } from 'src/validation';
 
 export class JobIdParamDto {
   @IsNotEmpty()
diff --git a/server/src/domain/library/library.dto.ts b/server/src/domain/library/library.dto.ts
index 26faf43407..f65779fb85 100644
--- a/server/src/domain/library/library.dto.ts
+++ b/server/src/domain/library/library.dto.ts
@@ -1,7 +1,7 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { ArrayMaxSize, ArrayUnique, IsEnum, IsNotEmpty, IsString } from 'class-validator';
-import { Optional, ValidateBoolean, ValidateUUID } from 'src/domain/domain.util';
 import { LibraryEntity, LibraryType } from 'src/infra/entities/library.entity';
+import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
 
 export class CreateLibraryDto {
   @IsEnum(LibraryType)
diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts
index 7ad781f283..46b4d11f5b 100644
--- a/server/src/domain/library/library.service.ts
+++ b/server/src/domain/library/library.service.ts
@@ -7,7 +7,6 @@ import { Stats } from 'node:fs';
 import path, { basename, parse } from 'node:path';
 import picomatch from 'picomatch';
 import { mimeTypes } from 'src/domain/domain.constant';
-import { handlePromiseError, usePagination, validateCronExpression } from 'src/domain/domain.util';
 import { JOBS_ASSET_PAGINATION_SIZE, JobName } from 'src/domain/job/job.constants';
 import { IBaseJob, IEntityJob, ILibraryFileJob, ILibraryRefreshJob } from 'src/domain/job/job.interface';
 import {
@@ -35,6 +34,8 @@ import { SystemConfigCore } from 'src/domain/system-config/system-config.core';
 import { AssetType } from 'src/infra/entities/asset.entity';
 import { LibraryEntity, LibraryType } from 'src/infra/entities/library.entity';
 import { ImmichLogger } from 'src/infra/logger';
+import { handlePromiseError, usePagination } from 'src/utils';
+import { validateCronExpression } from 'src/validation';
 
 const LIBRARY_SCAN_BATCH_SIZE = 5000;
 
diff --git a/server/src/domain/media/media.service.ts b/server/src/domain/media/media.service.ts
index bb0c2503d9..f06f9ed28d 100644
--- a/server/src/domain/media/media.service.ts
+++ b/server/src/domain/media/media.service.ts
@@ -1,5 +1,4 @@
 import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common';
-import { usePagination } from 'src/domain/domain.util';
 import { JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from 'src/domain/job/job.constants';
 import { IBaseJob, IEntityJob } from 'src/domain/job/job.interface';
 import {
@@ -39,6 +38,7 @@ import {
   VideoCodec,
 } from 'src/infra/entities/system-config.entity';
 import { ImmichLogger } from 'src/infra/logger';
+import { usePagination } from 'src/utils';
 
 @Injectable()
 export class MediaService {
diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts
index 4f45b98533..360f916acb 100644
--- a/server/src/domain/metadata/metadata.service.ts
+++ b/server/src/domain/metadata/metadata.service.ts
@@ -6,7 +6,6 @@ import { Duration } from 'luxon';
 import { constants } from 'node:fs/promises';
 import path from 'node:path';
 import { Subscription } from 'rxjs';
-import { handlePromiseError, usePagination } from 'src/domain/domain.util';
 import { JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from 'src/domain/job/job.constants';
 import { IBaseJob, IEntityJob, ISidecarWriteJob } from 'src/domain/job/job.interface';
 import { IAlbumRepository } from 'src/domain/repositories/album.repository';
@@ -26,6 +25,7 @@ import { FeatureFlag, SystemConfigCore } from 'src/domain/system-config/system-c
 import { AssetEntity, AssetType } from 'src/infra/entities/asset.entity';
 import { ExifEntity } from 'src/infra/entities/exif.entity';
 import { ImmichLogger } from 'src/infra/logger';
+import { handlePromiseError, usePagination } from 'src/utils';
 
 /** look for a date from these tags (in order) */
 const EXIF_DATE_TAGS: Array<keyof Tags> = [
diff --git a/server/src/domain/person/person.dto.ts b/server/src/domain/person/person.dto.ts
index 9ac66332f8..f3699499aa 100644
--- a/server/src/domain/person/person.dto.ts
+++ b/server/src/domain/person/person.dto.ts
@@ -2,9 +2,9 @@ import { ApiProperty } from '@nestjs/swagger';
 import { Type } from 'class-transformer';
 import { IsArray, IsNotEmpty, IsString, MaxDate, ValidateNested } from 'class-validator';
 import { AuthDto } from 'src/domain/auth/auth.dto';
-import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/domain/domain.util';
 import { AssetFaceEntity } from 'src/infra/entities/asset-face.entity';
 import { PersonEntity } from 'src/infra/entities/person.entity';
+import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
 
 export class PersonCreateDto {
   /**
diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts
index b0aa3c33ac..a4b1282d32 100644
--- a/server/src/domain/person/person.service.spec.ts
+++ b/server/src/domain/person/person.service.spec.ts
@@ -1,6 +1,5 @@
 import { BadRequestException, NotFoundException } from '@nestjs/common';
 import { BulkIdErrorReason } from 'src/domain/asset/response-dto/asset-ids-response.dto';
-import { CacheControl, ImmichFileResponse } from 'src/domain/domain.util';
 import { JobName } from 'src/domain/job/job.constants';
 import { PersonResponseDto, mapFaces, mapPerson } from 'src/domain/person/person.dto';
 import { PersonService } from 'src/domain/person/person.service';
@@ -16,6 +15,7 @@ import { IStorageRepository } from 'src/domain/repositories/storage.repository';
 import { ISystemConfigRepository } from 'src/domain/repositories/system-config.repository';
 import { AssetFaceEntity } from 'src/infra/entities/asset-face.entity';
 import { Colorspace, SystemConfigKey } from 'src/infra/entities/system-config.entity';
+import { CacheControl, ImmichFileResponse } from 'src/utils';
 import { assetStub } from 'test/fixtures/asset.stub';
 import { authStub } from 'test/fixtures/auth.stub';
 import { faceStub } from 'test/fixtures/face.stub';
diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts
index 1928191a3e..46538e04f3 100644
--- a/server/src/domain/person/person.service.ts
+++ b/server/src/domain/person/person.service.ts
@@ -4,7 +4,6 @@ import { BulkIdErrorReason, BulkIdResponseDto } from 'src/domain/asset/response-
 import { AssetResponseDto, mapAsset } from 'src/domain/asset/response-dto/asset-response.dto';
 import { AuthDto } from 'src/domain/auth/auth.dto';
 import { mimeTypes } from 'src/domain/domain.constant';
-import { CacheControl, ImmichFileResponse, usePagination } from 'src/domain/domain.util';
 import { JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from 'src/domain/job/job.constants';
 import { IBaseJob, IDeferrableJob, IEntityJob } from 'src/domain/job/job.interface';
 import { FACE_THUMBNAIL_SIZE } from 'src/domain/media/media.constant';
@@ -39,6 +38,7 @@ import { SystemConfigCore } from 'src/domain/system-config/system-config.core';
 import { PersonPathType } from 'src/infra/entities/move.entity';
 import { PersonEntity } from 'src/infra/entities/person.entity';
 import { ImmichLogger } from 'src/infra/logger';
+import { CacheControl, ImmichFileResponse, usePagination } from 'src/utils';
 import { IsNull } from 'typeorm';
 
 @Injectable()
diff --git a/server/src/domain/repositories/asset.repository.ts b/server/src/domain/repositories/asset.repository.ts
index e8bbdeb389..336db8d70a 100644
--- a/server/src/domain/repositories/asset.repository.ts
+++ b/server/src/domain/repositories/asset.repository.ts
@@ -1,10 +1,10 @@
-import { Paginated, PaginationOptions } from 'src/domain/domain.util';
 import { ReverseGeocodeResult } from 'src/domain/repositories/metadata.repository';
 import { AssetSearchOptions, SearchExploreItem } from 'src/domain/repositories/search.repository';
 import { AssetOrder } from 'src/infra/entities/album.entity';
 import { AssetJobStatusEntity } from 'src/infra/entities/asset-job-status.entity';
 import { AssetEntity, AssetType } from 'src/infra/entities/asset.entity';
 import { ExifEntity } from 'src/infra/entities/exif.entity';
+import { Paginated, PaginationOptions } from 'src/utils';
 import { FindOptionsRelations, FindOptionsSelect } from 'typeorm';
 
 export type AssetStats = Record<AssetType, number>;
diff --git a/server/src/domain/repositories/person.repository.ts b/server/src/domain/repositories/person.repository.ts
index 93787f7b90..f00fce44ef 100644
--- a/server/src/domain/repositories/person.repository.ts
+++ b/server/src/domain/repositories/person.repository.ts
@@ -1,7 +1,7 @@
-import { Paginated, PaginationOptions } from 'src/domain/domain.util';
 import { AssetFaceEntity } from 'src/infra/entities/asset-face.entity';
 import { AssetEntity } from 'src/infra/entities/asset.entity';
 import { PersonEntity } from 'src/infra/entities/person.entity';
+import { Paginated, PaginationOptions } from 'src/utils';
 import { FindManyOptions, FindOptionsRelations, FindOptionsSelect } from 'typeorm';
 
 export const IPersonRepository = 'IPersonRepository';
diff --git a/server/src/domain/repositories/search.repository.ts b/server/src/domain/repositories/search.repository.ts
index 3dc9c1ea50..a54147f1f9 100644
--- a/server/src/domain/repositories/search.repository.ts
+++ b/server/src/domain/repositories/search.repository.ts
@@ -1,8 +1,8 @@
-import { Paginated } from 'src/domain/domain.util';
 import { AssetFaceEntity } from 'src/infra/entities/asset-face.entity';
 import { AssetEntity, AssetType } from 'src/infra/entities/asset.entity';
 import { GeodataPlacesEntity } from 'src/infra/entities/geodata-places.entity';
 import { SmartInfoEntity } from 'src/infra/entities/smart-info.entity';
+import { Paginated } from 'src/utils';
 
 export const ISearchRepository = 'ISearchRepository';
 
diff --git a/server/src/domain/search/dto/search-suggestion.dto.ts b/server/src/domain/search/dto/search-suggestion.dto.ts
index f2f70062a0..f702293d0c 100644
--- a/server/src/domain/search/dto/search-suggestion.dto.ts
+++ b/server/src/domain/search/dto/search-suggestion.dto.ts
@@ -1,6 +1,6 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
-import { Optional } from 'src/domain/domain.util';
+import { Optional } from 'src/validation';
 
 export enum SearchSuggestionType {
   COUNTRY = 'country',
diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts
index 0dcaa22fb5..52090b9456 100644
--- a/server/src/domain/search/dto/search.dto.ts
+++ b/server/src/domain/search/dto/search.dto.ts
@@ -1,10 +1,10 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { Type } from 'class-transformer';
 import { IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
-import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/domain/domain.util';
 import { AssetOrder } from 'src/infra/entities/album.entity';
 import { AssetType } from 'src/infra/entities/asset.entity';
 import { GeodataPlacesEntity } from 'src/infra/entities/geodata-places.entity';
+import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
 
 class BaseSearchDto {
   @ValidateUUID({ optional: true })
diff --git a/server/src/domain/server-info/server-info.service.ts b/server/src/domain/server-info/server-info.service.ts
index 77c675b87e..3ceb3039d9 100644
--- a/server/src/domain/server-info/server-info.service.ts
+++ b/server/src/domain/server-info/server-info.service.ts
@@ -1,7 +1,6 @@
 import { Inject, Injectable } from '@nestjs/common';
 import { DateTime } from 'luxon';
 import { Version, isDev, mimeTypes, serverVersion } from 'src/domain/domain.constant';
-import { asHumanReadable } from 'src/domain/domain.util';
 import { ClientEvent, ICommunicationRepository } from 'src/domain/repositories/communication.repository';
 import { IServerInfoRepository } from 'src/domain/repositories/server-info.repository';
 import { IStorageRepository } from 'src/domain/repositories/storage.repository';
@@ -21,6 +20,7 @@ import { StorageCore, StorageFolder } from 'src/domain/storage/storage.core';
 import { SystemConfigCore } from 'src/domain/system-config/system-config.core';
 import { SystemMetadataKey } from 'src/infra/entities/system-metadata.entity';
 import { ImmichLogger } from 'src/infra/logger';
+import { asHumanReadable } from 'src/utils';
 
 @Injectable()
 export class ServerInfoService {
diff --git a/server/src/domain/shared-link/shared-link.dto.ts b/server/src/domain/shared-link/shared-link.dto.ts
index ea529979c9..7ef9a77334 100644
--- a/server/src/domain/shared-link/shared-link.dto.ts
+++ b/server/src/domain/shared-link/shared-link.dto.ts
@@ -1,7 +1,7 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { IsEnum, IsString } from 'class-validator';
-import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/domain/domain.util';
 import { SharedLinkType } from 'src/infra/entities/shared-link.entity';
+import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
 
 export class SharedLinkCreateDto {
   @IsEnum(SharedLinkType)
diff --git a/server/src/domain/shared-link/shared-link.service.ts b/server/src/domain/shared-link/shared-link.service.ts
index a5a28b374f..b550854922 100644
--- a/server/src/domain/shared-link/shared-link.service.ts
+++ b/server/src/domain/shared-link/shared-link.service.ts
@@ -3,7 +3,6 @@ import { AccessCore, Permission } from 'src/domain/access/access.core';
 import { AssetIdsDto } from 'src/domain/asset/dto/asset-ids.dto';
 import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/domain/asset/response-dto/asset-ids-response.dto';
 import { AuthDto } from 'src/domain/auth/auth.dto';
-import { OpenGraphTags } from 'src/domain/domain.util';
 import { IAccessRepository } from 'src/domain/repositories/access.repository';
 import { ICryptoRepository } from 'src/domain/repositories/crypto.repository';
 import { ISharedLinkRepository } from 'src/domain/repositories/shared-link.repository';
@@ -15,6 +14,7 @@ import {
 import { SharedLinkCreateDto, SharedLinkEditDto, SharedLinkPasswordDto } from 'src/domain/shared-link/shared-link.dto';
 import { AssetEntity } from 'src/infra/entities/asset.entity';
 import { SharedLinkEntity, SharedLinkType } from 'src/infra/entities/shared-link.entity';
+import { OpenGraphTags } from 'src/utils';
 
 @Injectable()
 export class SharedLinkService {
diff --git a/server/src/domain/smart-info/dto/model-config.dto.ts b/server/src/domain/smart-info/dto/model-config.dto.ts
index 0b12e1cc61..e0977c4dae 100644
--- a/server/src/domain/smart-info/dto/model-config.dto.ts
+++ b/server/src/domain/smart-info/dto/model-config.dto.ts
@@ -1,8 +1,8 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { Type } from 'class-transformer';
 import { IsEnum, IsNotEmpty, IsNumber, IsString, Max, Min } from 'class-validator';
-import { Optional, ValidateBoolean } from 'src/domain/domain.util';
 import { CLIPMode, ModelType } from 'src/domain/repositories/machine-learning.repository';
+import { Optional, ValidateBoolean } from 'src/validation';
 
 export class ModelConfig {
   @ValidateBoolean()
diff --git a/server/src/domain/smart-info/smart-info.service.ts b/server/src/domain/smart-info/smart-info.service.ts
index 8f20f56d50..fdf9862f8c 100644
--- a/server/src/domain/smart-info/smart-info.service.ts
+++ b/server/src/domain/smart-info/smart-info.service.ts
@@ -1,5 +1,4 @@
 import { Inject, Injectable } from '@nestjs/common';
-import { usePagination } from 'src/domain/domain.util';
 import { JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from 'src/domain/job/job.constants';
 import { IBaseJob, IEntityJob } from 'src/domain/job/job.interface';
 import { IAssetRepository, WithoutProperty } from 'src/domain/repositories/asset.repository';
@@ -10,6 +9,7 @@ import { ISearchRepository } from 'src/domain/repositories/search.repository';
 import { ISystemConfigRepository } from 'src/domain/repositories/system-config.repository';
 import { SystemConfigCore } from 'src/domain/system-config/system-config.core';
 import { ImmichLogger } from 'src/infra/logger';
+import { usePagination } from 'src/utils';
 
 @Injectable()
 export class SmartInfoService {
diff --git a/server/src/domain/storage-template/storage-template.service.ts b/server/src/domain/storage-template/storage-template.service.ts
index 8028eb359b..f3e24c056d 100644
--- a/server/src/domain/storage-template/storage-template.service.ts
+++ b/server/src/domain/storage-template/storage-template.service.ts
@@ -4,7 +4,6 @@ import handlebar from 'handlebars';
 import { DateTime } from 'luxon';
 import path from 'node:path';
 import sanitize from 'sanitize-filename';
-import { getLivePhotoMotionFilename, usePagination } from 'src/domain/domain.util';
 import { JOBS_ASSET_PAGINATION_SIZE } from 'src/domain/job/job.constants';
 import { IEntityJob } from 'src/domain/job/job.interface';
 import { IAlbumRepository } from 'src/domain/repositories/album.repository';
@@ -33,6 +32,7 @@ import { AssetEntity, AssetType } from 'src/infra/entities/asset.entity';
 import { AssetPathType } from 'src/infra/entities/move.entity';
 import { SystemConfig } from 'src/infra/entities/system-config.entity';
 import { ImmichLogger } from 'src/infra/logger';
+import { getLivePhotoMotionFilename, usePagination } from 'src/utils';
 
 export interface MoveAssetMetadata {
   storageLabel: string | null;
diff --git a/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts b/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts
index 99077176bb..16f81b37ca 100644
--- a/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts
+++ b/server/src/domain/system-config/dto/system-config-ffmpeg.dto.ts
@@ -1,7 +1,6 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { Type } from 'class-transformer';
 import { IsEnum, IsInt, IsString, Max, Min } from 'class-validator';
-import { ValidateBoolean } from 'src/domain/domain.util';
 import {
   AudioCodec,
   CQMode,
@@ -10,6 +9,7 @@ import {
   TranscodePolicy,
   VideoCodec,
 } from 'src/infra/entities/system-config.entity';
+import { ValidateBoolean } from 'src/validation';
 
 export class SystemConfigFFmpegDto {
   @IsInt()
diff --git a/server/src/domain/system-config/dto/system-config-library.dto.ts b/server/src/domain/system-config/dto/system-config-library.dto.ts
index f322423dff..8c7501ae40 100644
--- a/server/src/domain/system-config/dto/system-config-library.dto.ts
+++ b/server/src/domain/system-config/dto/system-config-library.dto.ts
@@ -9,7 +9,7 @@ import {
   ValidatorConstraint,
   ValidatorConstraintInterface,
 } from 'class-validator';
-import { ValidateBoolean, validateCronExpression } from 'src/domain/domain.util';
+import { ValidateBoolean, validateCronExpression } from 'src/validation';
 
 const isEnabled = (config: SystemConfigLibraryScanDto) => config.enabled;
 
diff --git a/server/src/domain/system-config/dto/system-config-logging.dto.ts b/server/src/domain/system-config/dto/system-config-logging.dto.ts
index 0f1664ee39..f41b568a24 100644
--- a/server/src/domain/system-config/dto/system-config-logging.dto.ts
+++ b/server/src/domain/system-config/dto/system-config-logging.dto.ts
@@ -1,7 +1,7 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { IsEnum } from 'class-validator';
-import { ValidateBoolean } from 'src/domain/domain.util';
 import { LogLevel } from 'src/infra/entities/system-config.entity';
+import { ValidateBoolean } from 'src/validation';
 
 export class SystemConfigLoggingDto {
   @ValidateBoolean()
diff --git a/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts b/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts
index 1b33d5920f..0585859920 100644
--- a/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts
+++ b/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts
@@ -1,7 +1,7 @@
 import { Type } from 'class-transformer';
 import { IsObject, IsUrl, ValidateIf, ValidateNested } from 'class-validator';
-import { ValidateBoolean } from 'src/domain/domain.util';
 import { CLIPConfig, RecognitionConfig } from 'src/domain/smart-info/dto/model-config.dto';
+import { ValidateBoolean } from 'src/validation';
 
 export class SystemConfigMachineLearningDto {
   @ValidateBoolean()
diff --git a/server/src/domain/system-config/dto/system-config-map.dto.ts b/server/src/domain/system-config/dto/system-config-map.dto.ts
index fb8aac5935..9ec0abfa4e 100644
--- a/server/src/domain/system-config/dto/system-config-map.dto.ts
+++ b/server/src/domain/system-config/dto/system-config-map.dto.ts
@@ -1,5 +1,5 @@
 import { IsString } from 'class-validator';
-import { ValidateBoolean } from 'src/domain/domain.util';
+import { ValidateBoolean } from 'src/validation';
 
 export class SystemConfigMapDto {
   @ValidateBoolean()
diff --git a/server/src/domain/system-config/dto/system-config-new-version-check.dto.ts b/server/src/domain/system-config/dto/system-config-new-version-check.dto.ts
index 27afe1e680..7d5c5134f2 100644
--- a/server/src/domain/system-config/dto/system-config-new-version-check.dto.ts
+++ b/server/src/domain/system-config/dto/system-config-new-version-check.dto.ts
@@ -1,4 +1,4 @@
-import { ValidateBoolean } from 'src/domain/domain.util';
+import { ValidateBoolean } from 'src/validation';
 
 export class SystemConfigNewVersionCheckDto {
   @ValidateBoolean()
diff --git a/server/src/domain/system-config/dto/system-config-oauth.dto.ts b/server/src/domain/system-config/dto/system-config-oauth.dto.ts
index 13465b7722..9c7fc5f408 100644
--- a/server/src/domain/system-config/dto/system-config-oauth.dto.ts
+++ b/server/src/domain/system-config/dto/system-config-oauth.dto.ts
@@ -1,5 +1,5 @@
 import { IsNotEmpty, IsNumber, IsString, IsUrl, Min, ValidateIf } from 'class-validator';
-import { ValidateBoolean } from 'src/domain/domain.util';
+import { ValidateBoolean } from 'src/validation';
 
 const isEnabled = (config: SystemConfigOAuthDto) => config.enabled;
 const isOverrideEnabled = (config: SystemConfigOAuthDto) => config.mobileOverrideEnabled;
diff --git a/server/src/domain/system-config/dto/system-config-password-login.dto.ts b/server/src/domain/system-config/dto/system-config-password-login.dto.ts
index 3d98ae0459..8d49a7002d 100644
--- a/server/src/domain/system-config/dto/system-config-password-login.dto.ts
+++ b/server/src/domain/system-config/dto/system-config-password-login.dto.ts
@@ -1,4 +1,4 @@
-import { ValidateBoolean } from 'src/domain/domain.util';
+import { ValidateBoolean } from 'src/validation';
 
 export class SystemConfigPasswordLoginDto {
   @ValidateBoolean()
diff --git a/server/src/domain/system-config/dto/system-config-reverse-geocoding.dto.ts b/server/src/domain/system-config/dto/system-config-reverse-geocoding.dto.ts
index ad4bcaab64..8ff2866012 100644
--- a/server/src/domain/system-config/dto/system-config-reverse-geocoding.dto.ts
+++ b/server/src/domain/system-config/dto/system-config-reverse-geocoding.dto.ts
@@ -1,4 +1,4 @@
-import { ValidateBoolean } from 'src/domain/domain.util';
+import { ValidateBoolean } from 'src/validation';
 
 export class SystemConfigReverseGeocodingDto {
   @ValidateBoolean()
diff --git a/server/src/domain/system-config/dto/system-config-storage-template.dto.ts b/server/src/domain/system-config/dto/system-config-storage-template.dto.ts
index c35dcd4769..77204b46e8 100644
--- a/server/src/domain/system-config/dto/system-config-storage-template.dto.ts
+++ b/server/src/domain/system-config/dto/system-config-storage-template.dto.ts
@@ -1,5 +1,5 @@
 import { IsNotEmpty, IsString } from 'class-validator';
-import { ValidateBoolean } from 'src/domain/domain.util';
+import { ValidateBoolean } from 'src/validation';
 
 export class SystemConfigStorageTemplateDto {
   @ValidateBoolean()
diff --git a/server/src/domain/system-config/dto/system-config-trash.dto.ts b/server/src/domain/system-config/dto/system-config-trash.dto.ts
index 8765b4ff19..a9e5483ebf 100644
--- a/server/src/domain/system-config/dto/system-config-trash.dto.ts
+++ b/server/src/domain/system-config/dto/system-config-trash.dto.ts
@@ -1,7 +1,7 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { Type } from 'class-transformer';
 import { IsInt, Min } from 'class-validator';
-import { ValidateBoolean } from 'src/domain/domain.util';
+import { ValidateBoolean } from 'src/validation';
 
 export class SystemConfigTrashDto {
   @ValidateBoolean()
diff --git a/server/src/domain/tag/tag.dto.ts b/server/src/domain/tag/tag.dto.ts
index e0c27294a0..322f40acf0 100644
--- a/server/src/domain/tag/tag.dto.ts
+++ b/server/src/domain/tag/tag.dto.ts
@@ -1,7 +1,7 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
-import { Optional } from 'src/domain/domain.util';
 import { TagType } from 'src/infra/entities/tag.entity';
+import { Optional } from 'src/validation';
 
 export class CreateTagDto {
   @IsString()
diff --git a/server/src/domain/trash/trash.service.ts b/server/src/domain/trash/trash.service.ts
index 230ce77f33..aa95a48897 100644
--- a/server/src/domain/trash/trash.service.ts
+++ b/server/src/domain/trash/trash.service.ts
@@ -3,12 +3,12 @@ import { DateTime } from 'luxon';
 import { AccessCore, Permission } from 'src/domain/access/access.core';
 import { BulkIdsDto } from 'src/domain/asset/response-dto/asset-ids-response.dto';
 import { AuthDto } from 'src/domain/auth/auth.dto';
-import { usePagination } from 'src/domain/domain.util';
 import { JOBS_ASSET_PAGINATION_SIZE, JobName } from 'src/domain/job/job.constants';
 import { IAccessRepository } from 'src/domain/repositories/access.repository';
 import { IAssetRepository } from 'src/domain/repositories/asset.repository';
 import { ClientEvent, ICommunicationRepository } from 'src/domain/repositories/communication.repository';
 import { IJobRepository } from 'src/domain/repositories/job.repository';
+import { usePagination } from 'src/utils';
 
 export class TrashService {
   private access: AccessCore;
diff --git a/server/src/domain/user/dto/create-user.dto.ts b/server/src/domain/user/dto/create-user.dto.ts
index aa295cb2b5..7861c58c25 100644
--- a/server/src/domain/user/dto/create-user.dto.ts
+++ b/server/src/domain/user/dto/create-user.dto.ts
@@ -1,7 +1,7 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { Transform } from 'class-transformer';
 import { IsEmail, IsNotEmpty, IsNumber, IsPositive, IsString } from 'class-validator';
-import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/domain/domain.util';
+import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
 
 export class CreateUserDto {
   @IsEmail({ require_tld: false })
diff --git a/server/src/domain/user/dto/delete-user.dto.ts b/server/src/domain/user/dto/delete-user.dto.ts
index 09b1c5bf35..aa41e18aad 100644
--- a/server/src/domain/user/dto/delete-user.dto.ts
+++ b/server/src/domain/user/dto/delete-user.dto.ts
@@ -1,4 +1,4 @@
-import { ValidateBoolean } from 'src/domain/domain.util';
+import { ValidateBoolean } from 'src/validation';
 
 export class DeleteUserDto {
   @ValidateBoolean({ optional: true })
diff --git a/server/src/domain/user/dto/update-user.dto.ts b/server/src/domain/user/dto/update-user.dto.ts
index 605017e548..c7c47c4cff 100644
--- a/server/src/domain/user/dto/update-user.dto.ts
+++ b/server/src/domain/user/dto/update-user.dto.ts
@@ -1,8 +1,8 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { Transform } from 'class-transformer';
 import { IsEmail, IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID } from 'class-validator';
-import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/domain/domain.util';
 import { UserAvatarColor } from 'src/infra/entities/user.entity';
+import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
 
 export class UpdateUserDto {
   @Optional()
diff --git a/server/src/domain/user/user.service.spec.ts b/server/src/domain/user/user.service.spec.ts
index 8fed91c71c..d590d7fda2 100644
--- a/server/src/domain/user/user.service.spec.ts
+++ b/server/src/domain/user/user.service.spec.ts
@@ -5,7 +5,6 @@ import {
   NotFoundException,
 } from '@nestjs/common';
 import { when } from 'jest-when';
-import { CacheControl, ImmichFileResponse } from 'src/domain/domain.util';
 import { JobName } from 'src/domain/job/job.constants';
 import { IAlbumRepository } from 'src/domain/repositories/album.repository';
 import { ICryptoRepository } from 'src/domain/repositories/crypto.repository';
@@ -18,6 +17,7 @@ import { UpdateUserDto } from 'src/domain/user/dto/update-user.dto';
 import { mapUser } from 'src/domain/user/response-dto/user-response.dto';
 import { UserService } from 'src/domain/user/user.service';
 import { UserEntity, UserStatus } from 'src/infra/entities/user.entity';
+import { CacheControl, ImmichFileResponse } from 'src/utils';
 import { authStub } from 'test/fixtures/auth.stub';
 import { systemConfigStub } from 'test/fixtures/system-config.stub';
 import { userStub } from 'test/fixtures/user.stub';
diff --git a/server/src/domain/user/user.service.ts b/server/src/domain/user/user.service.ts
index 18f485b003..6eca5ff559 100644
--- a/server/src/domain/user/user.service.ts
+++ b/server/src/domain/user/user.service.ts
@@ -2,7 +2,6 @@ import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundEx
 import { DateTime } from 'luxon';
 import { randomBytes } from 'node:crypto';
 import { AuthDto } from 'src/domain/auth/auth.dto';
-import { CacheControl, ImmichFileResponse } from 'src/domain/domain.util';
 import { JobName } from 'src/domain/job/job.constants';
 import { IEntityJob } from 'src/domain/job/job.interface';
 import { IAlbumRepository } from 'src/domain/repositories/album.repository';
@@ -25,6 +24,7 @@ import { UserResponseDto, mapUser } from 'src/domain/user/response-dto/user-resp
 import { UserCore } from 'src/domain/user/user.core';
 import { UserEntity, UserStatus } from 'src/infra/entities/user.entity';
 import { ImmichLogger } from 'src/infra/logger';
+import { CacheControl, ImmichFileResponse } from 'src/utils';
 
 @Injectable()
 export class UserService {
diff --git a/server/src/immich/api-v1/asset/asset.controller.ts b/server/src/immich/api-v1/asset/asset.controller.ts
index 3faf33641d..0496c75be6 100644
--- a/server/src/immich/api-v1/asset/asset.controller.ts
+++ b/server/src/immich/api-v1/asset/asset.controller.ts
@@ -29,16 +29,15 @@ import { AssetFileUploadResponseDto } from 'src/immich/api-v1/asset/response-dto
 import { CheckExistingAssetsResponseDto } from 'src/immich/api-v1/asset/response-dto/check-existing-assets-response.dto';
 import { CuratedLocationsResponseDto } from 'src/immich/api-v1/asset/response-dto/curated-locations-response.dto';
 import { CuratedObjectsResponseDto } from 'src/immich/api-v1/asset/response-dto/curated-objects-response.dto';
-import FileNotEmptyValidator from 'src/immich/api-v1/validation/file-not-empty-validator';
 import { Auth, Authenticated, FileResponse, SharedLinkRoute } from 'src/immich/app.guard';
 import { sendFile } from 'src/immich/app.utils';
-import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto';
 import {
   FileUploadInterceptor,
   ImmichFile,
   Route,
   mapToUploadFile,
 } from 'src/immich/interceptors/file-upload.interceptor';
+import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
 
 interface UploadFiles {
   assetData: ImmichFile[];
diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts
index 78f7fa24fa..613e8f2ed4 100644
--- a/server/src/immich/api-v1/asset/asset.service.ts
+++ b/server/src/immich/api-v1/asset/asset.service.ts
@@ -10,7 +10,6 @@ import { UploadFile } from 'src/domain/asset/asset.service';
 import { AssetResponseDto, mapAsset } from 'src/domain/asset/response-dto/asset-response.dto';
 import { AuthDto } from 'src/domain/auth/auth.dto';
 import { mimeTypes } from 'src/domain/domain.constant';
-import { CacheControl, ImmichFileResponse, getLivePhotoMotionFilename } from 'src/domain/domain.util';
 import { JobName } from 'src/domain/job/job.constants';
 import { IAccessRepository } from 'src/domain/repositories/access.repository';
 import { IAssetRepository } from 'src/domain/repositories/asset.repository';
@@ -37,6 +36,7 @@ import { CuratedObjectsResponseDto } from 'src/immich/api-v1/asset/response-dto/
 import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/infra/entities/asset.entity';
 import { LibraryType } from 'src/infra/entities/library.entity';
 import { ImmichLogger } from 'src/infra/logger';
+import { CacheControl, ImmichFileResponse, getLivePhotoMotionFilename } from 'src/utils';
 import { QueryFailedError } from 'typeorm';
 
 @Injectable()
diff --git a/server/src/immich/api-v1/asset/dto/asset-search.dto.ts b/server/src/immich/api-v1/asset/dto/asset-search.dto.ts
index 499ec7499a..97d0aa1fa5 100644
--- a/server/src/immich/api-v1/asset/dto/asset-search.dto.ts
+++ b/server/src/immich/api-v1/asset/dto/asset-search.dto.ts
@@ -1,7 +1,7 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { Type } from 'class-transformer';
 import { IsInt, IsUUID } from 'class-validator';
-import { Optional, ValidateBoolean, ValidateDate } from 'src/domain/domain.util';
+import { Optional, ValidateBoolean, ValidateDate } from 'src/validation';
 
 export class AssetSearchDto {
   @ValidateBoolean({ optional: true })
diff --git a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts
index 90b2d22451..d16a9c05cd 100644
--- a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts
+++ b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts
@@ -1,7 +1,7 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { IsNotEmpty, IsString } from 'class-validator';
 import { UploadFieldName } from 'src/domain/asset/asset.service';
-import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/domain/domain.util';
+import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
 
 export class CreateAssetDto {
   @ValidateUUID({ optional: true })
diff --git a/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts b/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts
index 65c6a7debb..6c709eb022 100644
--- a/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts
+++ b/server/src/immich/api-v1/asset/dto/get-asset-thumbnail.dto.ts
@@ -1,6 +1,6 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { IsEnum } from 'class-validator';
-import { Optional } from 'src/domain/domain.util';
+import { Optional } from 'src/validation';
 
 export enum GetAssetThumbnailFormatEnum {
   JPEG = 'JPEG',
diff --git a/server/src/immich/api-v1/asset/dto/serve-file.dto.ts b/server/src/immich/api-v1/asset/dto/serve-file.dto.ts
index cc8d8fd15d..8b3147fc2d 100644
--- a/server/src/immich/api-v1/asset/dto/serve-file.dto.ts
+++ b/server/src/immich/api-v1/asset/dto/serve-file.dto.ts
@@ -1,5 +1,5 @@
 import { ApiProperty } from '@nestjs/swagger';
-import { ValidateBoolean } from 'src/domain/domain.util';
+import { ValidateBoolean } from 'src/validation';
 
 export class ServeFileDto {
   @ValidateBoolean({ optional: true })
diff --git a/server/src/immich/api-v1/validation/file-not-empty-validator.ts b/server/src/immich/api-v1/validation/file-not-empty-validator.ts
deleted file mode 100644
index 21f93a952c..0000000000
--- a/server/src/immich/api-v1/validation/file-not-empty-validator.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { FileValidator, Injectable } from '@nestjs/common';
-
-@Injectable()
-export default class FileNotEmptyValidator extends FileValidator {
-  constructor(private requiredFields: string[]) {
-    super({});
-    this.requiredFields = requiredFields;
-  }
-
-  isValid(files?: any): boolean {
-    if (!files) {
-      return false;
-    }
-
-    return this.requiredFields.every((field) => files[field]);
-  }
-
-  buildErrorMessage(): string {
-    return `Field(s) ${this.requiredFields.join(', ')} should not be empty`;
-  }
-}
diff --git a/server/src/immich/api-v1/validation/parse-me-uuid-pipe.ts b/server/src/immich/api-v1/validation/parse-me-uuid-pipe.ts
deleted file mode 100644
index 4329af0112..0000000000
--- a/server/src/immich/api-v1/validation/parse-me-uuid-pipe.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { ArgumentMetadata, Injectable, ParseUUIDPipe } from '@nestjs/common';
-
-@Injectable()
-export class ParseMeUUIDPipe extends ParseUUIDPipe {
-  async transform(value: string, metadata: ArgumentMetadata) {
-    if (value == 'me') {
-      return value;
-    }
-    return super.transform(value, metadata);
-  }
-}
diff --git a/server/src/immich/app.service.ts b/server/src/immich/app.service.ts
index 8cbb6cf235..f7f1664be5 100644
--- a/server/src/immich/app.service.ts
+++ b/server/src/immich/app.service.ts
@@ -6,13 +6,13 @@ import { join } from 'node:path';
 import { AuthService } from 'src/domain/auth/auth.service';
 import { DatabaseService } from 'src/domain/database/database.service';
 import { ONE_HOUR, WEB_ROOT } from 'src/domain/domain.constant';
-import { OpenGraphTags } from 'src/domain/domain.util';
 import { JobService } from 'src/domain/job/job.service';
 import { ServerInfoService } from 'src/domain/server-info/server-info.service';
 import { SharedLinkService } from 'src/domain/shared-link/shared-link.service';
 import { StorageService } from 'src/domain/storage/storage.service';
 import { SystemConfigService } from 'src/domain/system-config/system-config.service';
 import { ImmichLogger } from 'src/infra/logger';
+import { OpenGraphTags } from 'src/utils';
 
 const render = (index: string, meta: OpenGraphTags) => {
   const tags = `
diff --git a/server/src/immich/app.utils.ts b/server/src/immich/app.utils.ts
index 4d78ffb242..48f01d8dd5 100644
--- a/server/src/immich/app.utils.ts
+++ b/server/src/immich/app.utils.ts
@@ -15,10 +15,10 @@ import path, { isAbsolute } from 'node:path';
 import { promisify } from 'node:util';
 import { IMMICH_ACCESS_COOKIE, IMMICH_API_KEY_HEADER, IMMICH_API_KEY_NAME } from 'src/domain/auth/auth.constant';
 import { serverVersion } from 'src/domain/domain.constant';
-import { CacheControl, ImmichFileResponse, isConnectionAborted } from 'src/domain/domain.util';
 import { ImmichReadStream } from 'src/domain/repositories/storage.repository';
 import { Metadata } from 'src/immich/app.guard';
 import { ImmichLogger } from 'src/infra/logger';
+import { CacheControl, ImmichFileResponse, isConnectionAborted } from 'src/utils';
 
 type SendFile = Parameters<Response['sendFile']>;
 type SendFileOptions = SendFile[1];
diff --git a/server/src/immich/controllers/activity.controller.ts b/server/src/immich/controllers/activity.controller.ts
index c79a564077..45f58e9f24 100644
--- a/server/src/immich/controllers/activity.controller.ts
+++ b/server/src/immich/controllers/activity.controller.ts
@@ -11,7 +11,7 @@ import {
 import { ActivityService } from 'src/domain/activity/activity.service';
 import { AuthDto } from 'src/domain/auth/auth.dto';
 import { Auth, Authenticated } from 'src/immich/app.guard';
-import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto';
+import { UUIDParamDto } from 'src/validation';
 
 @ApiTags('Activity')
 @Controller('activity')
diff --git a/server/src/immich/controllers/album.controller.ts b/server/src/immich/controllers/album.controller.ts
index 8981de2124..12e35a0f6d 100644
--- a/server/src/immich/controllers/album.controller.ts
+++ b/server/src/immich/controllers/album.controller.ts
@@ -9,9 +9,8 @@ import { AlbumInfoDto } from 'src/domain/album/dto/album.dto';
 import { GetAlbumsDto } from 'src/domain/album/dto/get-albums.dto';
 import { BulkIdResponseDto, BulkIdsDto } from 'src/domain/asset/response-dto/asset-ids-response.dto';
 import { AuthDto } from 'src/domain/auth/auth.dto';
-import { ParseMeUUIDPipe } from 'src/immich/api-v1/validation/parse-me-uuid-pipe';
 import { Auth, Authenticated, SharedLinkRoute } from 'src/immich/app.guard';
-import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto';
+import { ParseMeUUIDPipe, UUIDParamDto } from 'src/validation';
 
 @ApiTags('Album')
 @Controller('album')
diff --git a/server/src/immich/controllers/api-key.controller.ts b/server/src/immich/controllers/api-key.controller.ts
index b9b449610c..96afe676dd 100644
--- a/server/src/immich/controllers/api-key.controller.ts
+++ b/server/src/immich/controllers/api-key.controller.ts
@@ -9,7 +9,7 @@ import {
 import { APIKeyService } from 'src/domain/api-key/api-key.service';
 import { AuthDto } from 'src/domain/auth/auth.dto';
 import { Auth, Authenticated } from 'src/immich/app.guard';
-import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto';
+import { UUIDParamDto } from 'src/validation';
 
 @ApiTags('API Key')
 @Controller('api-key')
diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts
index d085dbe570..ee35c98ddc 100644
--- a/server/src/immich/controllers/asset.controller.ts
+++ b/server/src/immich/controllers/asset.controller.ts
@@ -21,8 +21,8 @@ import { AuthDto } from 'src/domain/auth/auth.dto';
 import { MetadataSearchDto } from 'src/domain/search/dto/search.dto';
 import { SearchService } from 'src/domain/search/search.service';
 import { Auth, Authenticated, SharedLinkRoute } from 'src/immich/app.guard';
-import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto';
 import { Route } from 'src/immich/interceptors/file-upload.interceptor';
+import { UUIDParamDto } from 'src/validation';
 
 @ApiTags('Asset')
 @Controller('assets')
diff --git a/server/src/immich/controllers/auth.controller.ts b/server/src/immich/controllers/auth.controller.ts
index b2dece148d..7f9fea47cf 100644
--- a/server/src/immich/controllers/auth.controller.ts
+++ b/server/src/immich/controllers/auth.controller.ts
@@ -15,7 +15,7 @@ import {
 import { AuthService, LoginDetails } from 'src/domain/auth/auth.service';
 import { UserResponseDto, mapUser } from 'src/domain/user/response-dto/user-response.dto';
 import { Auth, Authenticated, GetLoginDetails, PublicRoute } from 'src/immich/app.guard';
-import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto';
+import { UUIDParamDto } from 'src/validation';
 
 @ApiTags('Authentication')
 @Controller('auth')
diff --git a/server/src/immich/controllers/download.controller.ts b/server/src/immich/controllers/download.controller.ts
index 41849a41d3..0cfaa3ea80 100644
--- a/server/src/immich/controllers/download.controller.ts
+++ b/server/src/immich/controllers/download.controller.ts
@@ -7,7 +7,7 @@ import { DownloadInfoDto, DownloadResponseDto } from 'src/domain/download/downlo
 import { DownloadService } from 'src/domain/download/download.service';
 import { Auth, Authenticated, FileResponse, SharedLinkRoute } from 'src/immich/app.guard';
 import { asStreamableFile, sendFile } from 'src/immich/app.utils';
-import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto';
+import { UUIDParamDto } from 'src/validation';
 
 @ApiTags('Download')
 @Controller('download')
diff --git a/server/src/immich/controllers/dto/uuid-param.dto.ts b/server/src/immich/controllers/dto/uuid-param.dto.ts
deleted file mode 100644
index 6e1b5a36cc..0000000000
--- a/server/src/immich/controllers/dto/uuid-param.dto.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-import { ApiProperty } from '@nestjs/swagger';
-import { IsNotEmpty, IsUUID } from 'class-validator';
-
-export class UUIDParamDto {
-  @IsNotEmpty()
-  @IsUUID('4')
-  @ApiProperty({ format: 'uuid' })
-  id!: string;
-}
diff --git a/server/src/immich/controllers/face.controller.ts b/server/src/immich/controllers/face.controller.ts
index 0fab1bb919..e7d1c3fba8 100644
--- a/server/src/immich/controllers/face.controller.ts
+++ b/server/src/immich/controllers/face.controller.ts
@@ -4,7 +4,7 @@ import { AuthDto } from 'src/domain/auth/auth.dto';
 import { AssetFaceResponseDto, FaceDto, PersonResponseDto } from 'src/domain/person/person.dto';
 import { PersonService } from 'src/domain/person/person.service';
 import { Auth, Authenticated } from 'src/immich/app.guard';
-import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto';
+import { UUIDParamDto } from 'src/validation';
 
 @ApiTags('Face')
 @Controller('face')
diff --git a/server/src/immich/controllers/library.controller.ts b/server/src/immich/controllers/library.controller.ts
index d72461988c..5e362f6d0c 100644
--- a/server/src/immich/controllers/library.controller.ts
+++ b/server/src/immich/controllers/library.controller.ts
@@ -12,7 +12,7 @@ import {
 } from 'src/domain/library/library.dto';
 import { LibraryService } from 'src/domain/library/library.service';
 import { AdminRoute, Authenticated } from 'src/immich/app.guard';
-import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto';
+import { UUIDParamDto } from 'src/validation';
 
 @ApiTags('Library')
 @Controller('library')
diff --git a/server/src/immich/controllers/partner.controller.ts b/server/src/immich/controllers/partner.controller.ts
index 43c388e10f..c6bfea3557 100644
--- a/server/src/immich/controllers/partner.controller.ts
+++ b/server/src/immich/controllers/partner.controller.ts
@@ -5,7 +5,7 @@ import { PartnerResponseDto, UpdatePartnerDto } from 'src/domain/partner/partner
 import { PartnerService } from 'src/domain/partner/partner.service';
 import { PartnerDirection } from 'src/domain/repositories/partner.repository';
 import { Auth, Authenticated } from 'src/immich/app.guard';
-import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto';
+import { UUIDParamDto } from 'src/validation';
 
 @ApiTags('Partner')
 @Controller('partner')
diff --git a/server/src/immich/controllers/person.controller.ts b/server/src/immich/controllers/person.controller.ts
index c2b789678b..9ef791c3c9 100644
--- a/server/src/immich/controllers/person.controller.ts
+++ b/server/src/immich/controllers/person.controller.ts
@@ -18,7 +18,7 @@ import {
 import { PersonService } from 'src/domain/person/person.service';
 import { Auth, Authenticated, FileResponse } from 'src/immich/app.guard';
 import { sendFile } from 'src/immich/app.utils';
-import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto';
+import { UUIDParamDto } from 'src/validation';
 
 @ApiTags('Person')
 @Controller('person')
diff --git a/server/src/immich/controllers/shared-link.controller.ts b/server/src/immich/controllers/shared-link.controller.ts
index 39e4c084e3..b0717026c4 100644
--- a/server/src/immich/controllers/shared-link.controller.ts
+++ b/server/src/immich/controllers/shared-link.controller.ts
@@ -9,7 +9,7 @@ import { SharedLinkResponseDto } from 'src/domain/shared-link/shared-link-respon
 import { SharedLinkCreateDto, SharedLinkEditDto, SharedLinkPasswordDto } from 'src/domain/shared-link/shared-link.dto';
 import { SharedLinkService } from 'src/domain/shared-link/shared-link.service';
 import { Auth, Authenticated, SharedLinkRoute } from 'src/immich/app.guard';
-import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto';
+import { UUIDParamDto } from 'src/validation';
 
 @ApiTags('Shared Link')
 @Controller('shared-link')
diff --git a/server/src/immich/controllers/tag.controller.ts b/server/src/immich/controllers/tag.controller.ts
index 511d7b1d13..99715d5c72 100644
--- a/server/src/immich/controllers/tag.controller.ts
+++ b/server/src/immich/controllers/tag.controller.ts
@@ -8,7 +8,7 @@ import { TagResponseDto } from 'src/domain/tag/tag-response.dto';
 import { CreateTagDto, UpdateTagDto } from 'src/domain/tag/tag.dto';
 import { TagService } from 'src/domain/tag/tag.service';
 import { Auth, Authenticated } from 'src/immich/app.guard';
-import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto';
+import { UUIDParamDto } from 'src/validation';
 
 @ApiTags('Tag')
 @Controller('tag')
diff --git a/server/src/immich/controllers/user.controller.ts b/server/src/immich/controllers/user.controller.ts
index 10b16859ff..3f80765a97 100644
--- a/server/src/immich/controllers/user.controller.ts
+++ b/server/src/immich/controllers/user.controller.ts
@@ -26,8 +26,8 @@ import { UserResponseDto } from 'src/domain/user/response-dto/user-response.dto'
 import { UserService } from 'src/domain/user/user.service';
 import { AdminRoute, Auth, Authenticated, FileResponse } from 'src/immich/app.guard';
 import { sendFile } from 'src/immich/app.utils';
-import { UUIDParamDto } from 'src/immich/controllers/dto/uuid-param.dto';
 import { FileUploadInterceptor, Route } from 'src/immich/interceptors/file-upload.interceptor';
+import { UUIDParamDto } from 'src/validation';
 
 @ApiTags('User')
 @Controller(Route.USER)
diff --git a/server/src/immich/interceptors/error.interceptor.ts b/server/src/immich/interceptors/error.interceptor.ts
index 809464b374..70c1299224 100644
--- a/server/src/immich/interceptors/error.interceptor.ts
+++ b/server/src/immich/interceptors/error.interceptor.ts
@@ -7,9 +7,9 @@ import {
   NestInterceptor,
 } from '@nestjs/common';
 import { Observable, catchError, throwError } from 'rxjs';
-import { isConnectionAborted } from 'src/domain/domain.util';
 import { routeToErrorMessage } from 'src/immich/app.utils';
 import { ImmichLogger } from 'src/infra/logger';
+import { isConnectionAborted } from 'src/utils';
 
 @Injectable()
 export class ErrorInterceptor implements NestInterceptor {
diff --git a/server/src/immich/main.ts b/server/src/immich/main.ts
index adfe476a2f..310ac93023 100644
--- a/server/src/immich/main.ts
+++ b/server/src/immich/main.ts
@@ -4,11 +4,11 @@ import { json } from 'body-parser';
 import cookieParser from 'cookie-parser';
 import { existsSync } from 'node:fs';
 import sirv from 'sirv';
+import { excludePaths } from 'src/config';
 import { WEB_ROOT, envName, isDev, serverVersion } from 'src/domain/domain.constant';
 import { AppModule } from 'src/immich/app.module';
 import { AppService } from 'src/immich/app.service';
 import { useSwagger } from 'src/immich/app.utils';
-import { excludePaths } from 'src/infra/infra.config';
 import { otelSDK } from 'src/infra/instrumentation';
 import { ImmichLogger } from 'src/infra/logger';
 import { WebSocketAdapter } from 'src/infra/websocket.adapter';
diff --git a/server/src/infra/infra.module.ts b/server/src/infra/infra.module.ts
index 7ac208c1fc..b1636c5e2c 100644
--- a/server/src/infra/infra.module.ts
+++ b/server/src/infra/infra.module.ts
@@ -5,7 +5,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
 import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { OpenTelemetryModule } from 'nestjs-otel';
-import { immichAppConfig } from 'src/domain/domain.config';
+import { bullConfig, bullQueues, immichAppConfig } from 'src/config';
 import { IAccessRepository } from 'src/domain/repositories/access.repository';
 import { IActivityRepository } from 'src/domain/repositories/activity.repository';
 import { IAlbumRepository } from 'src/domain/repositories/album.repository';
@@ -35,7 +35,6 @@ import { IUserTokenRepository } from 'src/domain/repositories/user-token.reposit
 import { IUserRepository } from 'src/domain/repositories/user.repository';
 import { databaseConfig } from 'src/infra/database.config';
 import { databaseEntities } from 'src/infra/entities';
-import { bullConfig, bullQueues } from 'src/infra/infra.config';
 import { otelConfig } from 'src/infra/instrumentation';
 import { AccessRepository } from 'src/infra/repositories/access.repository';
 import { ActivityRepository } from 'src/infra/repositories/activity.repository';
diff --git a/server/src/infra/infra.util.ts b/server/src/infra/infra.util.ts
deleted file mode 100644
index 585d058e03..0000000000
--- a/server/src/infra/infra.util.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { SetMetadata } from '@nestjs/common';
-
-export const GENERATE_SQL_KEY = 'generate-sql-key';
-
-export interface GenerateSqlQueries {
-  name?: string;
-  params: unknown[];
-}
-
-/** Decorator to enable versioning/tracking of generated Sql */
-export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GENERATE_SQL_KEY, options);
-
-const UUID = '00000000-0000-4000-a000-000000000000';
-
-export const DummyValue = {
-  UUID,
-  UUID_SET: new Set([UUID]),
-  PAGINATION: { take: 10, skip: 0 },
-  EMAIL: 'user@immich.app',
-  STRING: 'abcdefghi',
-  BUFFER: Buffer.from('abcdefghi'),
-  DATE: new Date(),
-  TIME_BUCKET: '2024-01-01T00:00:00.000Z',
-};
-
-// PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the
-// maximum number of parameters is 65535. Any query that tries to bind more than that (e.g. searching
-// by a list of IDs) requires splitting the query into multiple chunks.
-// We are rounding down this limit, as queries commonly include other filters and parameters.
-export const DATABASE_PARAMETER_CHUNK_SIZE = 65_500;
diff --git a/server/src/infra/infra.utils.ts b/server/src/infra/infra.utils.ts
index 2cc5f629af..41c57904b6 100644
--- a/server/src/infra/infra.utils.ts
+++ b/server/src/infra/infra.utils.ts
@@ -1,16 +1,7 @@
 import _ from 'lodash';
-import {
-  Paginated,
-  PaginatedBuilderOptions,
-  PaginationMode,
-  PaginationOptions,
-  PaginationResult,
-  chunks,
-  setUnion,
-} from 'src/domain/domain.util';
 import { AssetSearchBuilderOptions } from 'src/domain/repositories/search.repository';
 import { AssetEntity } from 'src/infra/entities/asset.entity';
-import { DATABASE_PARAMETER_CHUNK_SIZE } from 'src/infra/infra.util';
+import { Paginated, PaginatedBuilderOptions, PaginationMode, PaginationOptions, PaginationResult } from 'src/utils';
 import {
   Between,
   FindManyOptions,
@@ -37,11 +28,6 @@ export function OptionalBetween<T>(from?: T, to?: T) {
   }
 }
 
-export const isValidInteger = (value: number, options: { min?: number; max?: number }): value is number => {
-  const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = options;
-  return Number.isInteger(value) && value >= min && value <= max;
-};
-
 function paginationHelper<Entity extends ObjectLiteral>(items: Entity[], take: number): PaginationResult<Entity> {
   const hasNextPage = items.length > take;
   items.splice(take);
@@ -86,70 +72,6 @@ export async function paginatedBuilder<Entity extends ObjectLiteral>(
 export const asVector = (embedding: number[], quote = false) =>
   quote ? `'[${embedding.join(',')}]'` : `[${embedding.join(',')}]`;
 
-/**
- * Wraps a method that takes a collection of parameters and sequentially calls it with chunks of the collection,
- * to overcome the maximum number of parameters allowed by the database driver.
- *
- * @param options.paramIndex The index of the function parameter to chunk. Defaults to 0.
- * @param options.flatten Whether to flatten the results. Defaults to false.
- */
-export function Chunked(options: { paramIndex?: number; mergeFn?: (results: any) => any } = {}): MethodDecorator {
-  return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
-    const originalMethod = descriptor.value;
-    const parameterIndex = options.paramIndex ?? 0;
-    descriptor.value = async function (...arguments_: any[]) {
-      const argument = arguments_[parameterIndex];
-
-      // Early return if argument length is less than or equal to the chunk size.
-      if (
-        (Array.isArray(argument) && argument.length <= DATABASE_PARAMETER_CHUNK_SIZE) ||
-        (argument instanceof Set && argument.size <= DATABASE_PARAMETER_CHUNK_SIZE)
-      ) {
-        return await originalMethod.apply(this, arguments_);
-      }
-
-      return Promise.all(
-        chunks(argument, DATABASE_PARAMETER_CHUNK_SIZE).map(async (chunk) => {
-          await Reflect.apply(originalMethod, this, [
-            ...arguments_.slice(0, parameterIndex),
-            chunk,
-            ...arguments_.slice(parameterIndex + 1),
-          ]);
-        }),
-      ).then((results) => (options.mergeFn ? options.mergeFn(results) : results));
-    };
-  };
-}
-
-export function ChunkedArray(options?: { paramIndex?: number }): MethodDecorator {
-  return Chunked({ ...options, mergeFn: _.flatten });
-}
-
-export function ChunkedSet(options?: { paramIndex?: number }): MethodDecorator {
-  return Chunked({ ...options, mergeFn: setUnion });
-}
-
-// https://stackoverflow.com/a/74898678
-export function DecorateAll(
-  decorator: <T>(
-    target: any,
-    propertyKey: string,
-    descriptor: TypedPropertyDescriptor<T>,
-  ) => TypedPropertyDescriptor<T> | void,
-) {
-  return (target: any) => {
-    const descriptors = Object.getOwnPropertyDescriptors(target.prototype);
-    for (const [propName, descriptor] of Object.entries(descriptors)) {
-      const isMethod = typeof descriptor.value == 'function' && propName !== 'constructor';
-      if (!isMethod) {
-        continue;
-      }
-      decorator({ ...target, constructor: { ...target.constructor, name: target.name } as any }, propName, descriptor);
-      Object.defineProperty(target.prototype, propName, descriptor);
-    }
-  };
-}
-
 export function searchAssetBuilder(
   builder: SelectQueryBuilder<AssetEntity>,
   options: AssetSearchBuilderOptions,
diff --git a/server/src/infra/instrumentation.ts b/server/src/infra/instrumentation.ts
index 2c912660ee..a30f0523a6 100644
--- a/server/src/infra/instrumentation.ts
+++ b/server/src/infra/instrumentation.ts
@@ -13,9 +13,9 @@ import { snakeCase, startCase } from 'lodash';
 import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
 import { copyMetadataFromFunctionToFunction } from 'nestjs-otel/lib/opentelemetry.utils';
 import { performance } from 'node:perf_hooks';
+import { excludePaths } from 'src/config';
+import { DecorateAll } from 'src/decorators';
 import { serverVersion } from 'src/domain/domain.constant';
-import { excludePaths } from 'src/infra/infra.config';
-import { DecorateAll } from 'src/infra/infra.utils';
 
 let metricsEnabled = process.env.IMMICH_METRICS === 'true';
 const hostMetrics =
diff --git a/server/src/infra/repositories/access.repository.ts b/server/src/infra/repositories/access.repository.ts
index 1f96226a6f..d16d6a6aa9 100644
--- a/server/src/infra/repositories/access.repository.ts
+++ b/server/src/infra/repositories/access.repository.ts
@@ -1,4 +1,5 @@
 import { InjectRepository } from '@nestjs/typeorm';
+import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
 import { IAccessRepository } from 'src/domain/repositories/access.repository';
 import { ActivityEntity } from 'src/infra/entities/activity.entity';
 import { AlbumEntity } from 'src/infra/entities/album.entity';
@@ -9,8 +10,6 @@ import { PartnerEntity } from 'src/infra/entities/partner.entity';
 import { PersonEntity } from 'src/infra/entities/person.entity';
 import { SharedLinkEntity } from 'src/infra/entities/shared-link.entity';
 import { UserTokenEntity } from 'src/infra/entities/user-token.entity';
-import { DummyValue, GenerateSql } from 'src/infra/infra.util';
-import { ChunkedSet } from 'src/infra/infra.utils';
 import { Instrumentation } from 'src/infra/instrumentation';
 import { Brackets, In, Repository } from 'typeorm';
 
diff --git a/server/src/infra/repositories/activity.repository.ts b/server/src/infra/repositories/activity.repository.ts
index afa52ec3a4..1cfe3e264c 100644
--- a/server/src/infra/repositories/activity.repository.ts
+++ b/server/src/infra/repositories/activity.repository.ts
@@ -1,8 +1,8 @@
 import { Injectable } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
+import { DummyValue, GenerateSql } from 'src/decorators';
 import { IActivityRepository } from 'src/domain/repositories/activity.repository';
 import { ActivityEntity } from 'src/infra/entities/activity.entity';
-import { DummyValue, GenerateSql } from 'src/infra/infra.util';
 import { Instrumentation } from 'src/infra/instrumentation';
 import { IsNull, Repository } from 'typeorm';
 
diff --git a/server/src/infra/repositories/album.repository.ts b/server/src/infra/repositories/album.repository.ts
index 86b73866f4..b31a894b5e 100644
--- a/server/src/infra/repositories/album.repository.ts
+++ b/server/src/infra/repositories/album.repository.ts
@@ -1,7 +1,7 @@
 import { Injectable } from '@nestjs/common';
 import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
 import _ from 'lodash';
-import { setUnion } from 'src/domain/domain.util';
+import { Chunked, ChunkedArray, DATABASE_PARAMETER_CHUNK_SIZE, DummyValue, GenerateSql } from 'src/decorators';
 import {
   AlbumAsset,
   AlbumAssetCount,
@@ -12,9 +12,8 @@ import {
 import { dataSource } from 'src/infra/database.config';
 import { AlbumEntity } from 'src/infra/entities/album.entity';
 import { AssetEntity } from 'src/infra/entities/asset.entity';
-import { DATABASE_PARAMETER_CHUNK_SIZE, DummyValue, GenerateSql } from 'src/infra/infra.util';
-import { Chunked, ChunkedArray } from 'src/infra/infra.utils';
 import { Instrumentation } from 'src/infra/instrumentation';
+import { setUnion } from 'src/utils';
 import { DataSource, FindOptionsOrder, FindOptionsRelations, In, IsNull, Not, Repository } from 'typeorm';
 
 @Instrumentation()
diff --git a/server/src/infra/repositories/api-key.repository.ts b/server/src/infra/repositories/api-key.repository.ts
index bdc9e6d0bf..5bacfded5f 100644
--- a/server/src/infra/repositories/api-key.repository.ts
+++ b/server/src/infra/repositories/api-key.repository.ts
@@ -1,8 +1,8 @@
 import { Injectable } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
+import { DummyValue, GenerateSql } from 'src/decorators';
 import { IKeyRepository } from 'src/domain/repositories/api-key.repository';
 import { APIKeyEntity } from 'src/infra/entities/api-key.entity';
-import { DummyValue, GenerateSql } from 'src/infra/infra.util';
 import { Instrumentation } from 'src/infra/instrumentation';
 import { Repository } from 'typeorm';
 
diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts
index 93f2e42a18..a57e852d28 100644
--- a/server/src/infra/repositories/asset.repository.ts
+++ b/server/src/infra/repositories/asset.repository.ts
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { DateTime } from 'luxon';
 import path from 'node:path';
-import { Paginated, PaginationMode, PaginationOptions } from 'src/domain/domain.util';
+import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
 import {
   AssetBuilderOptions,
   AssetCreate,
@@ -30,16 +30,9 @@ import { AssetJobStatusEntity } from 'src/infra/entities/asset-job-status.entity
 import { AssetEntity, AssetType } from 'src/infra/entities/asset.entity';
 import { ExifEntity } from 'src/infra/entities/exif.entity';
 import { SmartInfoEntity } from 'src/infra/entities/smart-info.entity';
-import { DummyValue, GenerateSql } from 'src/infra/infra.util';
-import {
-  Chunked,
-  ChunkedArray,
-  OptionalBetween,
-  paginate,
-  paginatedBuilder,
-  searchAssetBuilder,
-} from 'src/infra/infra.utils';
+import { OptionalBetween, paginate, paginatedBuilder, searchAssetBuilder } from 'src/infra/infra.utils';
 import { Instrumentation } from 'src/infra/instrumentation';
+import { Paginated, PaginationMode, PaginationOptions } from 'src/utils';
 import {
   Brackets,
   FindOptionsRelations,
diff --git a/server/src/infra/repositories/database.repository.ts b/server/src/infra/repositories/database.repository.ts
index a83bcf80f8..8720ad8d70 100644
--- a/server/src/infra/repositories/database.repository.ts
+++ b/server/src/infra/repositories/database.repository.ts
@@ -12,9 +12,9 @@ import {
   extName,
 } from 'src/domain/repositories/database.repository';
 import { vectorExt } from 'src/infra/database.config';
-import { isValidInteger } from 'src/infra/infra.utils';
 import { Instrumentation } from 'src/infra/instrumentation';
 import { ImmichLogger } from 'src/infra/logger';
+import { isValidInteger } from 'src/validation';
 import { DataSource, EntityManager, QueryRunner } from 'typeorm';
 
 @Instrumentation()
diff --git a/server/src/infra/repositories/job.repository.ts b/server/src/infra/repositories/job.repository.ts
index eac535e361..856c7fae06 100644
--- a/server/src/infra/repositories/job.repository.ts
+++ b/server/src/infra/repositories/job.repository.ts
@@ -5,6 +5,7 @@ import { SchedulerRegistry } from '@nestjs/schedule';
 import { Job, JobsOptions, Processor, Queue, Worker, WorkerOptions } from 'bullmq';
 import { CronJob, CronTime } from 'cron';
 import { setTimeout } from 'node:timers/promises';
+import { bullConfig } from 'src/config';
 import { JOBS_TO_QUEUE, JobName, QueueName } from 'src/domain/job/job.constants';
 import {
   IJobRepository,
@@ -13,7 +14,6 @@ import {
   QueueCleanType,
   QueueStatus,
 } from 'src/domain/repositories/job.repository';
-import { bullConfig } from 'src/infra/infra.config';
 import { Instrumentation } from 'src/infra/instrumentation';
 import { ImmichLogger } from 'src/infra/logger';
 
diff --git a/server/src/infra/repositories/library.repository.ts b/server/src/infra/repositories/library.repository.ts
index f61d4beb6e..f9045b9c88 100644
--- a/server/src/infra/repositories/library.repository.ts
+++ b/server/src/infra/repositories/library.repository.ts
@@ -1,9 +1,9 @@
 import { Injectable } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
+import { DummyValue, GenerateSql } from 'src/decorators';
 import { LibraryStatsResponseDto } from 'src/domain/library/library.dto';
 import { ILibraryRepository } from 'src/domain/repositories/library.repository';
 import { LibraryEntity, LibraryType } from 'src/infra/entities/library.entity';
-import { DummyValue, GenerateSql } from 'src/infra/infra.util';
 import { Instrumentation } from 'src/infra/instrumentation';
 import { IsNull, Not } from 'typeorm';
 import { Repository } from 'typeorm/repository/Repository.js';
diff --git a/server/src/infra/repositories/media.repository.ts b/server/src/infra/repositories/media.repository.ts
index 0088e312f2..f2d59ea95d 100644
--- a/server/src/infra/repositories/media.repository.ts
+++ b/server/src/infra/repositories/media.repository.ts
@@ -3,7 +3,6 @@ import fs from 'node:fs/promises';
 import { Writable } from 'node:stream';
 import { promisify } from 'node:util';
 import sharp from 'sharp';
-import { handlePromiseError } from 'src/domain/domain.util';
 import {
   CropOptions,
   IMediaRepository,
@@ -14,6 +13,7 @@ import {
 import { Colorspace } from 'src/infra/entities/system-config.entity';
 import { Instrumentation } from 'src/infra/instrumentation';
 import { ImmichLogger } from 'src/infra/logger';
+import { handlePromiseError } from 'src/utils';
 
 const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe);
 sharp.concurrency(0);
diff --git a/server/src/infra/repositories/metadata.repository.ts b/server/src/infra/repositories/metadata.repository.ts
index e81ba8dcbe..365a3543f5 100644
--- a/server/src/infra/repositories/metadata.repository.ts
+++ b/server/src/infra/repositories/metadata.repository.ts
@@ -6,6 +6,7 @@ import { getName } from 'i18n-iso-countries';
 import { createReadStream, existsSync } from 'node:fs';
 import { readFile } from 'node:fs/promises';
 import readLine from 'node:readline';
+import { DummyValue, GenerateSql } from 'src/decorators';
 import {
   citiesFile,
   geodataAdmin1Path,
@@ -23,7 +24,6 @@ import { ISystemMetadataRepository } from 'src/domain/repositories/system-metada
 import { ExifEntity } from 'src/infra/entities/exif.entity';
 import { GeodataPlacesEntity } from 'src/infra/entities/geodata-places.entity';
 import { SystemMetadataKey } from 'src/infra/entities/system-metadata.entity';
-import { DummyValue, GenerateSql } from 'src/infra/infra.util';
 import { Instrumentation } from 'src/infra/instrumentation';
 import { ImmichLogger } from 'src/infra/logger';
 import { DataSource, QueryRunner, Repository } from 'typeorm';
diff --git a/server/src/infra/repositories/move.repository.ts b/server/src/infra/repositories/move.repository.ts
index 04807db248..8482fc228b 100644
--- a/server/src/infra/repositories/move.repository.ts
+++ b/server/src/infra/repositories/move.repository.ts
@@ -1,8 +1,8 @@
 import { Injectable } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
+import { DummyValue, GenerateSql } from 'src/decorators';
 import { IMoveRepository, MoveCreate } from 'src/domain/repositories/move.repository';
 import { MoveEntity, PathType } from 'src/infra/entities/move.entity';
-import { DummyValue, GenerateSql } from 'src/infra/infra.util';
 import { Instrumentation } from 'src/infra/instrumentation';
 import { Repository } from 'typeorm';
 
diff --git a/server/src/infra/repositories/person.repository.ts b/server/src/infra/repositories/person.repository.ts
index df76ad9d0d..e0d707ca1b 100644
--- a/server/src/infra/repositories/person.repository.ts
+++ b/server/src/infra/repositories/person.repository.ts
@@ -1,6 +1,6 @@
 import { InjectRepository } from '@nestjs/typeorm';
 import _ from 'lodash';
-import { Paginated, PaginationOptions } from 'src/domain/domain.util';
+import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
 import {
   AssetFaceId,
   IPersonRepository,
@@ -13,9 +13,9 @@ import {
 import { AssetFaceEntity } from 'src/infra/entities/asset-face.entity';
 import { AssetEntity } from 'src/infra/entities/asset.entity';
 import { PersonEntity } from 'src/infra/entities/person.entity';
-import { DummyValue, GenerateSql } from 'src/infra/infra.util';
-import { ChunkedArray, asVector, paginate } from 'src/infra/infra.utils';
+import { asVector, paginate } from 'src/infra/infra.utils';
 import { Instrumentation } from 'src/infra/instrumentation';
+import { Paginated, PaginationOptions } from 'src/utils';
 import { FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm';
 
 @Instrumentation()
diff --git a/server/src/infra/repositories/search.repository.ts b/server/src/infra/repositories/search.repository.ts
index 5676bd8b53..6ba546ea9a 100644
--- a/server/src/infra/repositories/search.repository.ts
+++ b/server/src/infra/repositories/search.repository.ts
@@ -1,6 +1,6 @@
 import { Injectable } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
-import { Paginated, PaginationMode, PaginationResult } from 'src/domain/domain.util';
+import { DummyValue, GenerateSql } from 'src/decorators';
 import { DatabaseExtension } from 'src/domain/repositories/database.repository';
 import {
   AssetSearchOptions,
@@ -18,10 +18,11 @@ import { AssetEntity, AssetType } from 'src/infra/entities/asset.entity';
 import { GeodataPlacesEntity } from 'src/infra/entities/geodata-places.entity';
 import { SmartInfoEntity } from 'src/infra/entities/smart-info.entity';
 import { SmartSearchEntity } from 'src/infra/entities/smart-search.entity';
-import { DummyValue, GenerateSql } from 'src/infra/infra.util';
-import { asVector, isValidInteger, paginatedBuilder, searchAssetBuilder } from 'src/infra/infra.utils';
+import { asVector, paginatedBuilder, searchAssetBuilder } from 'src/infra/infra.utils';
 import { Instrumentation } from 'src/infra/instrumentation';
 import { ImmichLogger } from 'src/infra/logger';
+import { Paginated, PaginationMode, PaginationResult } from 'src/utils';
+import { isValidInteger } from 'src/validation';
 import { Repository, SelectQueryBuilder } from 'typeorm';
 
 @Instrumentation()
diff --git a/server/src/infra/repositories/shared-link.repository.ts b/server/src/infra/repositories/shared-link.repository.ts
index 1b1ed7fe2a..e2878acf4d 100644
--- a/server/src/infra/repositories/shared-link.repository.ts
+++ b/server/src/infra/repositories/shared-link.repository.ts
@@ -1,8 +1,8 @@
 import { Injectable } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
+import { DummyValue, GenerateSql } from 'src/decorators';
 import { ISharedLinkRepository } from 'src/domain/repositories/shared-link.repository';
 import { SharedLinkEntity } from 'src/infra/entities/shared-link.entity';
-import { DummyValue, GenerateSql } from 'src/infra/infra.util';
 import { Instrumentation } from 'src/infra/instrumentation';
 import { Repository } from 'typeorm';
 
diff --git a/server/src/infra/repositories/system-config.repository.ts b/server/src/infra/repositories/system-config.repository.ts
index f354c87e67..555a3c254c 100644
--- a/server/src/infra/repositories/system-config.repository.ts
+++ b/server/src/infra/repositories/system-config.repository.ts
@@ -1,9 +1,8 @@
 import { InjectRepository } from '@nestjs/typeorm';
 import { readFile } from 'node:fs/promises';
+import { Chunked, DummyValue, GenerateSql } from 'src/decorators';
 import { ISystemConfigRepository } from 'src/domain/repositories/system-config.repository';
 import { SystemConfigEntity } from 'src/infra/entities/system-config.entity';
-import { DummyValue, GenerateSql } from 'src/infra/infra.util';
-import { Chunked } from 'src/infra/infra.utils';
 import { Instrumentation } from 'src/infra/instrumentation';
 import { In, Repository } from 'typeorm';
 
diff --git a/server/src/infra/repositories/user-token.repository.ts b/server/src/infra/repositories/user-token.repository.ts
index f2b79a05f2..7d16bd0cff 100644
--- a/server/src/infra/repositories/user-token.repository.ts
+++ b/server/src/infra/repositories/user-token.repository.ts
@@ -1,8 +1,8 @@
 import { Injectable } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
+import { DummyValue, GenerateSql } from 'src/decorators';
 import { IUserTokenRepository } from 'src/domain/repositories/user-token.repository';
 import { UserTokenEntity } from 'src/infra/entities/user-token.entity';
-import { DummyValue, GenerateSql } from 'src/infra/infra.util';
 import { Instrumentation } from 'src/infra/instrumentation';
 import { Repository } from 'typeorm';
 
diff --git a/server/src/infra/repositories/user.repository.ts b/server/src/infra/repositories/user.repository.ts
index e71d629360..27692dd660 100644
--- a/server/src/infra/repositories/user.repository.ts
+++ b/server/src/infra/repositories/user.repository.ts
@@ -1,5 +1,6 @@
 import { Injectable } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
+import { DummyValue, GenerateSql } from 'src/decorators';
 import {
   IUserRepository,
   UserFindOptions,
@@ -8,7 +9,6 @@ import {
 } from 'src/domain/repositories/user.repository';
 import { AssetEntity } from 'src/infra/entities/asset.entity';
 import { UserEntity } from 'src/infra/entities/user.entity';
-import { DummyValue, GenerateSql } from 'src/infra/infra.util';
 import { Instrumentation } from 'src/infra/instrumentation';
 import { IsNull, Not, Repository } from 'typeorm';
 
diff --git a/server/src/infra/sql-generator/index.ts b/server/src/infra/sql-generator/index.ts
index c365587a96..0821d53cd3 100644
--- a/server/src/infra/sql-generator/index.ts
+++ b/server/src/infra/sql-generator/index.ts
@@ -5,10 +5,10 @@ import { Test } from '@nestjs/testing';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { mkdir, rm, writeFile } from 'node:fs/promises';
 import { join } from 'node:path';
+import { GENERATE_SQL_KEY, GenerateSqlQueries } from 'src/decorators';
 import { ISystemConfigRepository } from 'src/domain/repositories/system-config.repository';
 import { databaseConfig } from 'src/infra/database.config';
 import { databaseEntities } from 'src/infra/entities';
-import { GENERATE_SQL_KEY, GenerateSqlQueries } from 'src/infra/infra.util';
 import { AccessRepository } from 'src/infra/repositories/access.repository';
 import { AlbumRepository } from 'src/infra/repositories/album.repository';
 import { ApiKeyRepository } from 'src/infra/repositories/api-key.repository';
diff --git a/server/src/microservices/utils/exif/coordinates.ts b/server/src/microservices/utils/exif/coordinates.ts
deleted file mode 100644
index 7e0d816794..0000000000
--- a/server/src/microservices/utils/exif/coordinates.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { isNumberInRange } from 'src/microservices/utils/numbers';
-
-export function parseLatitude(input: string | number | null): number | null {
-  if (input === null) {
-    return null;
-  }
-  const latitude = typeof input === 'string' ? Number.parseFloat(input) : input;
-
-  if (isNumberInRange(latitude, -90, 90)) {
-    return latitude;
-  }
-  return null;
-}
-
-export function parseLongitude(input: string | number | null): number | null {
-  if (input === null) {
-    return null;
-  }
-
-  const longitude = typeof input === 'string' ? Number.parseFloat(input) : input;
-
-  if (isNumberInRange(longitude, -180, 180)) {
-    return longitude;
-  }
-  return null;
-}
diff --git a/server/src/microservices/utils/numbers.spec.ts b/server/src/microservices/utils/numbers.spec.ts
deleted file mode 100644
index 633b8b1d45..0000000000
--- a/server/src/microservices/utils/numbers.spec.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { isDecimalNumber, isNumberInRange, toNumberOrNull } from 'src/microservices/utils/numbers';
-
-describe('checks if a number is a decimal number', () => {
-  it('returns false for non-decimal numbers', () => {
-    expect(isDecimalNumber(Number.NaN)).toBe(false);
-    expect(isDecimalNumber(Number.POSITIVE_INFINITY)).toBe(false);
-    expect(isDecimalNumber(Number.NEGATIVE_INFINITY)).toBe(false);
-  });
-
-  it('returns true for decimal numbers', () => {
-    expect(isDecimalNumber(0)).toBe(true);
-    expect(isDecimalNumber(-0)).toBe(true);
-    expect(isDecimalNumber(10.123_45)).toBe(true);
-    expect(isDecimalNumber(Number.MAX_VALUE)).toBe(true);
-    expect(isDecimalNumber(Number.MIN_VALUE)).toBe(true);
-  });
-});
-
-describe('checks if a number is within a range', () => {
-  it('returns false for numbers outside the range', () => {
-    expect(isNumberInRange(0, 10, 10)).toBe(false);
-    expect(isNumberInRange(0.01, 10, 10)).toBe(false);
-    expect(isNumberInRange(50.1, 0, 50)).toBe(false);
-  });
-
-  it('returns true for numbers inside the range', () => {
-    expect(isNumberInRange(0, 0, 50)).toBe(true);
-    expect(isNumberInRange(50, 0, 50)).toBe(true);
-    expect(isNumberInRange(-50.123_45, -50.123_45, 0)).toBe(true);
-  });
-});
-
-describe('converts input to a number or null', () => {
-  it('returns null for invalid inputs', () => {
-    expect(toNumberOrNull(null)).toBeNull();
-    // eslint-disable-next-line unicorn/no-useless-undefined
-    expect(toNumberOrNull(undefined)).toBeNull();
-    expect(toNumberOrNull('')).toBeNull();
-    expect(toNumberOrNull(Number.NaN)).toBeNull();
-  });
-
-  it('returns a number for valid inputs', () => {
-    expect(toNumberOrNull(0)).toBeCloseTo(0);
-    expect(toNumberOrNull('0')).toBeCloseTo(0);
-    expect(toNumberOrNull('-123.45')).toBeCloseTo(-123.45);
-  });
-});
diff --git a/server/src/microservices/utils/numbers.ts b/server/src/microservices/utils/numbers.ts
deleted file mode 100644
index cd6e81d2a2..0000000000
--- a/server/src/microservices/utils/numbers.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-export function isDecimalNumber(number_: number): boolean {
-  return !Number.isNaN(number_) && Number.isFinite(number_);
-}
-
-/**
- * Check if `num` is a valid number and is between `start` and `end` (inclusive)
- */
-export function isNumberInRange(number_: number, start: number, end: number): boolean {
-  return isDecimalNumber(number_) && number_ >= start && number_ <= end;
-}
-
-export function toNumberOrNull(input: number | string | null | undefined): number | null {
-  if (input === null || input === undefined) {
-    return null;
-  }
-
-  const number_ = typeof input === 'string' ? Number.parseFloat(input) : input;
-  return isDecimalNumber(number_) ? number_ : null;
-}
diff --git a/server/src/microservices/utils/exif/coordinates.spec.ts b/server/src/utils.spec.ts
similarity index 53%
rename from server/src/microservices/utils/exif/coordinates.spec.ts
rename to server/src/utils.spec.ts
index 083ea31bc4..c5ae5f6e57 100644
--- a/server/src/microservices/utils/exif/coordinates.spec.ts
+++ b/server/src/utils.spec.ts
@@ -1,4 +1,50 @@
-import { parseLatitude, parseLongitude } from 'src/microservices/utils/exif/coordinates';
+import { isDecimalNumber, isNumberInRange, parseLatitude, parseLongitude, toNumberOrNull } from 'src/utils';
+
+describe('checks if a number is a decimal number', () => {
+  it('returns false for non-decimal numbers', () => {
+    expect(isDecimalNumber(Number.NaN)).toBe(false);
+    expect(isDecimalNumber(Number.POSITIVE_INFINITY)).toBe(false);
+    expect(isDecimalNumber(Number.NEGATIVE_INFINITY)).toBe(false);
+  });
+
+  it('returns true for decimal numbers', () => {
+    expect(isDecimalNumber(0)).toBe(true);
+    expect(isDecimalNumber(-0)).toBe(true);
+    expect(isDecimalNumber(10.123_45)).toBe(true);
+    expect(isDecimalNumber(Number.MAX_VALUE)).toBe(true);
+    expect(isDecimalNumber(Number.MIN_VALUE)).toBe(true);
+  });
+});
+
+describe('checks if a number is within a range', () => {
+  it('returns false for numbers outside the range', () => {
+    expect(isNumberInRange(0, 10, 10)).toBe(false);
+    expect(isNumberInRange(0.01, 10, 10)).toBe(false);
+    expect(isNumberInRange(50.1, 0, 50)).toBe(false);
+  });
+
+  it('returns true for numbers inside the range', () => {
+    expect(isNumberInRange(0, 0, 50)).toBe(true);
+    expect(isNumberInRange(50, 0, 50)).toBe(true);
+    expect(isNumberInRange(-50.123_45, -50.123_45, 0)).toBe(true);
+  });
+});
+
+describe('converts input to a number or null', () => {
+  it('returns null for invalid inputs', () => {
+    expect(toNumberOrNull(null)).toBeNull();
+    // eslint-disable-next-line unicorn/no-useless-undefined
+    expect(toNumberOrNull(undefined)).toBeNull();
+    expect(toNumberOrNull('')).toBeNull();
+    expect(toNumberOrNull(Number.NaN)).toBeNull();
+  });
+
+  it('returns a number for valid inputs', () => {
+    expect(toNumberOrNull(0)).toBeCloseTo(0);
+    expect(toNumberOrNull('0')).toBeCloseTo(0);
+    expect(toNumberOrNull('-123.45')).toBeCloseTo(-123.45);
+  });
+});
 
 describe('parsing latitude from string input', () => {
   it('returns null for invalid inputs', () => {
diff --git a/server/src/utils.ts b/server/src/utils.ts
new file mode 100644
index 0000000000..863dde7ca8
--- /dev/null
+++ b/server/src/utils.ts
@@ -0,0 +1,181 @@
+import { basename, extname } from 'node:path';
+import { ImmichLogger } from 'src/infra/logger';
+
+const KiB = Math.pow(1024, 1);
+const MiB = Math.pow(1024, 2);
+const GiB = Math.pow(1024, 3);
+const TiB = Math.pow(1024, 4);
+const PiB = Math.pow(1024, 5);
+
+export const HumanReadableSize = { KiB, MiB, GiB, TiB, PiB };
+
+export function asHumanReadable(bytes: number, precision = 1): string {
+  const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB'];
+
+  let magnitude = 0;
+  let remainder = bytes;
+  while (remainder >= 1024) {
+    if (magnitude + 1 < units.length) {
+      magnitude++;
+      remainder /= 1024;
+    } else {
+      break;
+    }
+  }
+
+  return `${remainder.toFixed(magnitude == 0 ? 0 : precision)} ${units[magnitude]}`;
+}
+
+export const isConnectionAborted = (error: Error | any) => error.code === 'ECONNABORTED';
+
+export function isDecimalNumber(number_: number): boolean {
+  return !Number.isNaN(number_) && Number.isFinite(number_);
+}
+
+/**
+ * Check if `num` is a valid number and is between `start` and `end` (inclusive)
+ */
+export function isNumberInRange(number_: number, start: number, end: number): boolean {
+  return isDecimalNumber(number_) && number_ >= start && number_ <= end;
+}
+
+export function toNumberOrNull(input: number | string | null | undefined): number | null {
+  if (input === null || input === undefined) {
+    return null;
+  }
+
+  const number_ = typeof input === 'string' ? Number.parseFloat(input) : input;
+  return isDecimalNumber(number_) ? number_ : null;
+}
+
+export function parseLatitude(input: string | number | null): number | null {
+  if (input === null) {
+    return null;
+  }
+  const latitude = typeof input === 'string' ? Number.parseFloat(input) : input;
+
+  if (isNumberInRange(latitude, -90, 90)) {
+    return latitude;
+  }
+  return null;
+}
+
+export function parseLongitude(input: string | number | null): number | null {
+  if (input === null) {
+    return null;
+  }
+
+  const longitude = typeof input === 'string' ? Number.parseFloat(input) : input;
+
+  if (isNumberInRange(longitude, -180, 180)) {
+    return longitude;
+  }
+  return null;
+}
+
+// NOTE: The following Set utils have been added here, to easily determine where they are used.
+//       They should be replaced with native Set operations, when they are added to the language.
+//       Proposal reference: https://github.com/tc39/proposal-set-methods
+
+export const setUnion = <T>(...sets: Set<T>[]): Set<T> => {
+  const union = new Set(sets[0]);
+  for (const set of sets.slice(1)) {
+    for (const element of set) {
+      union.add(element);
+    }
+  }
+  return union;
+};
+
+export const setDifference = <T>(setA: Set<T>, ...sets: Set<T>[]): Set<T> => {
+  const difference = new Set(setA);
+  for (const set of sets) {
+    for (const element of set) {
+      difference.delete(element);
+    }
+  }
+  return difference;
+};
+
+export const setIsSuperset = <T>(set: Set<T>, subset: Set<T>): boolean => {
+  for (const element of subset) {
+    if (!set.has(element)) {
+      return false;
+    }
+  }
+  return true;
+};
+
+export const setIsEqual = <T>(setA: Set<T>, setB: Set<T>): boolean => {
+  return setA.size === setB.size && setIsSuperset(setA, setB);
+};
+
+export const handlePromiseError = <T>(promise: Promise<T>, logger: ImmichLogger): void => {
+  promise.catch((error: Error | any) => logger.error(`Promise error: ${error}`, error?.stack));
+};
+
+export enum CacheControl {
+  PRIVATE_WITH_CACHE = 'private_with_cache',
+  PRIVATE_WITHOUT_CACHE = 'private_without_cache',
+  NONE = 'none',
+}
+
+export class ImmichFileResponse {
+  public readonly path!: string;
+  public readonly contentType!: string;
+  public readonly cacheControl!: CacheControl;
+
+  constructor(response: ImmichFileResponse) {
+    Object.assign(this, response);
+  }
+}
+
+export interface OpenGraphTags {
+  title: string;
+  description: string;
+  imageUrl?: string;
+}
+
+export function getFileNameWithoutExtension(path: string): string {
+  return basename(path, extname(path));
+}
+
+export function getLivePhotoMotionFilename(stillName: string, motionName: string) {
+  return getFileNameWithoutExtension(stillName) + extname(motionName);
+}
+
+export interface PaginationOptions {
+  take: number;
+  skip?: number;
+}
+
+export enum PaginationMode {
+  LIMIT_OFFSET = 'limit-offset',
+  SKIP_TAKE = 'skip-take',
+}
+
+export interface PaginatedBuilderOptions {
+  take: number;
+  skip?: number;
+  mode?: PaginationMode;
+}
+
+export interface PaginationResult<T> {
+  items: T[];
+  hasNextPage: boolean;
+}
+
+export type Paginated<T> = Promise<PaginationResult<T>>;
+
+export async function* usePagination<T>(
+  pageSize: number,
+  getNextPage: (pagination: PaginationOptions) => PaginationResult<T> | Paginated<T>,
+) {
+  let hasNextPage = true;
+
+  for (let skip = 0; hasNextPage; skip += pageSize) {
+    const result = await getNextPage({ take: pageSize, skip });
+    hasNextPage = result.hasNextPage;
+    yield result.items;
+  }
+}
diff --git a/server/src/validation.ts b/server/src/validation.ts
new file mode 100644
index 0000000000..bc1dbae819
--- /dev/null
+++ b/server/src/validation.ts
@@ -0,0 +1,164 @@
+import {
+  ArgumentMetadata,
+  BadRequestException,
+  FileValidator,
+  Injectable,
+  ParseUUIDPipe,
+  applyDecorators,
+} from '@nestjs/common';
+import { ApiProperty } from '@nestjs/swagger';
+import { Transform } from 'class-transformer';
+import {
+  IsArray,
+  IsBoolean,
+  IsDate,
+  IsNotEmpty,
+  IsOptional,
+  IsString,
+  IsUUID,
+  ValidateIf,
+  ValidationOptions,
+  isDateString,
+} from 'class-validator';
+import { CronJob } from 'cron';
+import sanitize from 'sanitize-filename';
+
+@Injectable()
+export class ParseMeUUIDPipe extends ParseUUIDPipe {
+  async transform(value: string, metadata: ArgumentMetadata) {
+    if (value == 'me') {
+      return value;
+    }
+    return super.transform(value, metadata);
+  }
+}
+
+@Injectable()
+export class FileNotEmptyValidator extends FileValidator {
+  constructor(private requiredFields: string[]) {
+    super({});
+    this.requiredFields = requiredFields;
+  }
+
+  isValid(files?: any): boolean {
+    if (!files) {
+      return false;
+    }
+
+    return this.requiredFields.every((field) => files[field]);
+  }
+
+  buildErrorMessage(): string {
+    return `Field(s) ${this.requiredFields.join(', ')} should not be empty`;
+  }
+}
+
+export class UUIDParamDto {
+  @IsNotEmpty()
+  @IsUUID('4')
+  @ApiProperty({ format: 'uuid' })
+  id!: string;
+}
+
+export interface OptionalOptions extends ValidationOptions {
+  nullable?: boolean;
+}
+
+/**
+ * Checks if value is missing and if so, ignores all validators.
+ *
+ * @param validationOptions {@link OptionalOptions}
+ *
+ * @see IsOptional exported from `class-validator.
+ */
+// https://stackoverflow.com/a/71353929
+export function Optional({ nullable, ...validationOptions }: OptionalOptions = {}) {
+  if (nullable === true) {
+    return IsOptional(validationOptions);
+  }
+
+  return ValidateIf((object: any, v: any) => v !== undefined, validationOptions);
+}
+
+type UUIDOptions = { optional?: boolean; each?: boolean };
+export const ValidateUUID = (options?: UUIDOptions) => {
+  const { optional, each } = { optional: false, each: false, ...options };
+  return applyDecorators(
+    IsUUID('4', { each }),
+    ApiProperty({ format: 'uuid' }),
+    optional ? Optional() : IsNotEmpty(),
+    each ? IsArray() : IsString(),
+  );
+};
+
+type DateOptions = { optional?: boolean; nullable?: boolean; format?: 'date' | 'date-time' };
+export const ValidateDate = (options?: DateOptions) => {
+  const { optional, nullable, format } = { optional: false, nullable: false, format: 'date-time', ...options };
+
+  const decorators = [
+    ApiProperty({ format }),
+    IsDate(),
+    optional ? Optional({ nullable: true }) : IsNotEmpty(),
+    Transform(({ key, value }) => {
+      if (value === null || value === undefined) {
+        return value;
+      }
+
+      if (!isDateString(value)) {
+        throw new BadRequestException(`${key} must be a date string`);
+      }
+
+      return new Date(value as string);
+    }),
+  ];
+
+  if (optional) {
+    decorators.push(Optional({ nullable }));
+  }
+
+  return applyDecorators(...decorators);
+};
+
+type BooleanOptions = { optional?: boolean };
+export const ValidateBoolean = (options?: BooleanOptions) => {
+  const { optional } = { optional: false, ...options };
+  const decorators = [
+    // ApiProperty(),
+    IsBoolean(),
+    Transform(({ value }) => {
+      if (value == 'true') {
+        return true;
+      } else if (value == 'false') {
+        return false;
+      }
+      return value;
+    }),
+  ];
+
+  if (optional) {
+    decorators.push(Optional());
+  }
+
+  return applyDecorators(...decorators);
+};
+
+export function validateCronExpression(expression: string) {
+  try {
+    new CronJob(expression, () => {});
+  } catch {
+    return false;
+  }
+
+  return true;
+}
+
+type IValue = { value: string };
+
+export const toEmail = ({ value }: IValue) => value?.toLowerCase();
+
+export const toSanitized = ({ value }: IValue) => sanitize((value || '').replaceAll('.', ''));
+
+export const isValidInteger = (value: number, options: { min?: number; max?: number }): value is number => {
+  const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = options;
+  return Number.isInteger(value) && value >= min && value <= max;
+};