diff --git a/packages/backend/src/core/ClipService.ts b/packages/backend/src/core/ClipService.ts
new file mode 100644
index 0000000000000000000000000000000000000000..59f213c00cb83f3a5a77477b85bf65e33895c72a
--- /dev/null
+++ b/packages/backend/src/core/ClipService.ts
@@ -0,0 +1,152 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import * as Redis from 'ioredis';
+import { DI } from '@/di-symbols.js';
+import type { ClipsRepository, MiNote, MiClip, ClipNotesRepository, NotesRepository } from '@/models/_.js';
+import { bindThis } from '@/decorators.js';
+import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
+import { RoleService } from '@/core/RoleService.js';
+import { IdService } from '@/core/IdService.js';
+import type { MiLocalUser } from '@/models/entities/User.js';
+
+@Injectable()
+export class ClipService {
+	public static NoSuchClipError = class extends Error {};
+	public static AlreadyAddedError = class extends Error {};
+	public static TooManyClipNotesError = class extends Error {};
+	public static TooManyClipsError = class extends Error {};
+
+	constructor(
+		@Inject(DI.redis)
+		private redisClient: Redis.Redis,
+
+		@Inject(DI.redisForSub)
+		private redisForSub: Redis.Redis,
+
+		@Inject(DI.clipsRepository)
+		private clipsRepository: ClipsRepository,
+
+		@Inject(DI.clipNotesRepository)
+		private clipNotesRepository: ClipNotesRepository,
+
+		@Inject(DI.notesRepository)
+		private notesRepository: NotesRepository,
+
+		private roleService: RoleService,
+		private idService: IdService,
+	) {
+	}
+
+	@bindThis
+	public async create(me: MiLocalUser, name: string, isPublic: boolean, description: string | null): Promise<MiClip> {
+		const currentCount = await this.clipsRepository.countBy({
+			userId: me.id,
+		});
+		if (currentCount > (await this.roleService.getUserPolicies(me.id)).clipLimit) {
+			throw new ClipService.TooManyClipsError();
+		}
+
+		const clip = await this.clipsRepository.insert({
+			id: this.idService.genId(),
+			createdAt: new Date(),
+			userId: me.id,
+			name: name,
+			isPublic: isPublic,
+			description: description,
+		}).then(x => this.clipsRepository.findOneByOrFail(x.identifiers[0]));
+
+		return clip;
+	}
+
+	@bindThis
+	public async update(me: MiLocalUser, clipId: MiClip['id'], name: string | undefined, isPublic: boolean | undefined, description: string | null | undefined): Promise<void> {
+		const clip = await this.clipsRepository.findOneBy({
+			id: clipId,
+			userId: me.id,
+		});
+
+		if (clip == null) {
+			throw new ClipService.NoSuchClipError();
+		}
+
+		await this.clipsRepository.update(clip.id, {
+			name: name,
+			description: description,
+			isPublic: isPublic,
+		});
+	}
+
+	@bindThis
+	public async delete(me: MiLocalUser, clipId: MiClip['id']): Promise<void> {
+		const clip = await this.clipsRepository.findOneBy({
+			id: clipId,
+			userId: me.id,
+		});
+
+		if (clip == null) {
+			throw new ClipService.NoSuchClipError();
+		}
+
+		await this.clipsRepository.delete(clip.id);
+	}
+
+	@bindThis
+	public async addNote(me: MiLocalUser, clipId: MiClip['id'], noteId: MiNote['id']): Promise<void> {
+		const clip = await this.clipsRepository.findOneBy({
+			id: clipId,
+			userId: me.id,
+		});
+
+		if (clip == null) {
+			throw new ClipService.NoSuchClipError();
+		}
+
+		const currentCount = await this.clipNotesRepository.countBy({
+			clipId: clip.id,
+		});
+		if (currentCount > (await this.roleService.getUserPolicies(me.id)).noteEachClipsLimit) {
+			throw new ClipService.TooManyClipNotesError();
+		}
+
+		try {
+			await this.clipNotesRepository.insert({
+				id: this.idService.genId(),
+				noteId: noteId,
+				clipId: clip.id,
+			});
+		} catch (e) {
+			if (isDuplicateKeyValueError(e)) {
+				throw new ClipService.AlreadyAddedError();
+			}
+		}
+
+		this.clipsRepository.update(clip.id, {
+			lastClippedAt: new Date(),
+		});
+
+		this.notesRepository.increment({ id: noteId }, 'clippedCount', 1);
+	}
+
+	@bindThis
+	public async removeNote(me: MiLocalUser, clipId: MiClip['id'], noteId: MiNote['id']): Promise<void> {
+		const clip = await this.clipsRepository.findOneBy({
+			id: clipId,
+			userId: me.id,
+		});
+
+		if (clip == null) {
+			throw new ClipService.NoSuchClipError();
+		}
+
+		await this.clipNotesRepository.delete({
+			noteId: noteId,
+			clipId: clip.id,
+		});
+
+		this.notesRepository.decrement({ id: noteId }, 'clippedCount', 1);
+	}
+}
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index 863f1a2fd58554371bd77a1f837052c874dba6b1..18271ee34679f06a7452fae9890ef18a64408440 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -57,6 +57,7 @@ import { ProxyAccountService } from './ProxyAccountService.js';
 import { UtilityService } from './UtilityService.js';
 import { FileInfoService } from './FileInfoService.js';
 import { SearchService } from './SearchService.js';
+import { ClipService } from './ClipService.js';
 import { ChartLoggerService } from './chart/ChartLoggerService.js';
 import FederationChart from './chart/charts/federation.js';
 import NotesChart from './chart/charts/notes.js';
@@ -181,6 +182,7 @@ const $WebhookService: Provider = { provide: 'WebhookService', useExisting: Webh
 const $UtilityService: Provider = { provide: 'UtilityService', useExisting: UtilityService };
 const $FileInfoService: Provider = { provide: 'FileInfoService', useExisting: FileInfoService };
 const $SearchService: Provider = { provide: 'SearchService', useExisting: SearchService };
+const $ClipService: Provider = { provide: 'ClipService', useExisting: ClipService };
 
 const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
 const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
@@ -309,6 +311,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		UtilityService,
 		FileInfoService,
 		SearchService,
+		ClipService,
 		ChartLoggerService,
 		FederationChart,
 		NotesChart,
@@ -430,6 +433,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$UtilityService,
 		$FileInfoService,
 		$SearchService,
+		$ClipService,
 		$ChartLoggerService,
 		$FederationChart,
 		$NotesChart,
@@ -552,6 +556,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		UtilityService,
 		FileInfoService,
 		SearchService,
+		ClipService,
 		FederationChart,
 		NotesChart,
 		UsersChart,
@@ -672,6 +677,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$UtilityService,
 		$FileInfoService,
 		$SearchService,
+		$ClipService,
 		$FederationChart,
 		$NotesChart,
 		$UsersChart,
diff --git a/packages/backend/src/server/api/endpoints/clips/add-note.ts b/packages/backend/src/server/api/endpoints/clips/add-note.ts
index 00b8bb09a852672e3b610ae0631bf7f6d8e68625..a3777e3ba6d84600af5578c05fd7e38e9cec7258 100644
--- a/packages/backend/src/server/api/endpoints/clips/add-note.ts
+++ b/packages/backend/src/server/api/endpoints/clips/add-note.ts
@@ -6,11 +6,7 @@
 import { Inject, Injectable } from '@nestjs/common';
 import ms from 'ms';
 import { Endpoint } from '@/server/api/endpoint-base.js';
-import { IdService } from '@/core/IdService.js';
-import { DI } from '@/di-symbols.js';
-import type { ClipNotesRepository, ClipsRepository, NotesRepository } from '@/models/_.js';
-import { GetterService } from '@/server/api/GetterService.js';
-import { RoleService } from '@/core/RoleService.js';
+import { ClipService } from '@/core/ClipService.js';
 import { ApiError } from '../../error.js';
 
 export const meta = {
@@ -66,63 +62,22 @@ export const paramDef = {
 @Injectable()
 export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
 	constructor(
-		@Inject(DI.clipsRepository)
-		private clipsRepository: ClipsRepository,
-
-		@Inject(DI.clipNotesRepository)
-		private clipNotesRepository: ClipNotesRepository,
-
-		@Inject(DI.notesRepository)
-		private notesRepository: NotesRepository,
-
-		private idService: IdService,
-		private roleService: RoleService,
-		private getterService: GetterService,
+		private clipService: ClipService,
 	) {
 		super(meta, paramDef, async (ps, me) => {
-			const clip = await this.clipsRepository.findOneBy({
-				id: ps.clipId,
-				userId: me.id,
-			});
-
-			if (clip == null) {
-				throw new ApiError(meta.errors.noSuchClip);
+			try {
+				await this.clipService.addNote(me, ps.clipId, ps.noteId);
+			} catch (e) {
+				if (e instanceof ClipService.NoSuchClipError) {
+					throw new ApiError(meta.errors.noSuchClip);
+				} else if (e instanceof ClipService.AlreadyAddedError) {
+					throw new ApiError(meta.errors.alreadyClipped);
+				} else if (e instanceof ClipService.TooManyClipNotesError) {
+					throw new ApiError(meta.errors.tooManyClipNotes);
+				} else {
+					throw e;
+				}
 			}
-
-			const note = await this.getterService.getNote(ps.noteId).catch(e => {
-				if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
-				throw e;
-			});
-
-			const exist = await this.clipNotesRepository.exist({
-				where: {
-					noteId: note.id,
-					clipId: clip.id,
-				},
-			});
-
-			if (exist) {
-				throw new ApiError(meta.errors.alreadyClipped);
-			}
-
-			const currentCount = await this.clipNotesRepository.countBy({
-				clipId: clip.id,
-			});
-			if (currentCount > (await this.roleService.getUserPolicies(me.id)).noteEachClipsLimit) {
-				throw new ApiError(meta.errors.tooManyClipNotes);
-			}
-
-			await this.clipNotesRepository.insert({
-				id: this.idService.genId(),
-				noteId: note.id,
-				clipId: clip.id,
-			});
-
-			this.clipsRepository.update(clip.id, {
-				lastClippedAt: new Date(),
-			});
-
-			this.notesRepository.increment({ id: note.id }, 'clippedCount', 1);
 		});
 	}
 }
diff --git a/packages/backend/src/server/api/endpoints/clips/create.ts b/packages/backend/src/server/api/endpoints/clips/create.ts
index 9677027cc0d6b29b6a5d1245f3f7bc740076c5bf..b4c7b52e727d9de4602e9fc3dd4c20d6d37a075b 100644
--- a/packages/backend/src/server/api/endpoints/clips/create.ts
+++ b/packages/backend/src/server/api/endpoints/clips/create.ts
@@ -5,12 +5,10 @@
 
 import { Inject, Injectable } from '@nestjs/common';
 import { Endpoint } from '@/server/api/endpoint-base.js';
-import { IdService } from '@/core/IdService.js';
-import type { ClipsRepository } from '@/models/_.js';
+import type { MiClip } from '@/models/_.js';
 import { ClipEntityService } from '@/core/entities/ClipEntityService.js';
-import { DI } from '@/di-symbols.js';
-import { RoleService } from '@/core/RoleService.js';
 import { ApiError } from '@/server/api/error.js';
+import { ClipService } from '@/core/ClipService.js';
 
 export const meta = {
 	tags: ['clips'],
@@ -49,30 +47,19 @@ export const paramDef = {
 @Injectable()
 export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
 	constructor(
-		@Inject(DI.clipsRepository)
-		private clipsRepository: ClipsRepository,
-
 		private clipEntityService: ClipEntityService,
-		private roleService: RoleService,
-		private idService: IdService,
+		private clipService: ClipService,
 	) {
 		super(meta, paramDef, async (ps, me) => {
-			const currentCount = await this.clipsRepository.countBy({
-				userId: me.id,
-			});
-			if (currentCount > (await this.roleService.getUserPolicies(me.id)).clipLimit) {
-				throw new ApiError(meta.errors.tooManyClips);
+			let clip: MiClip;
+			try {
+				clip = await this.clipService.create(me, ps.name, ps.isPublic, ps.description ?? null);
+			} catch (e) {
+				if (e instanceof ClipService.TooManyClipsError) {
+					throw new ApiError(meta.errors.tooManyClips);
+				}
+				throw e;
 			}
-
-			const clip = await this.clipsRepository.insert({
-				id: this.idService.genId(),
-				createdAt: new Date(),
-				userId: me.id,
-				name: ps.name,
-				isPublic: ps.isPublic,
-				description: ps.description,
-			}).then(x => this.clipsRepository.findOneByOrFail(x.identifiers[0]));
-
 			return await this.clipEntityService.pack(clip, me);
 		});
 	}
diff --git a/packages/backend/src/server/api/endpoints/clips/delete.ts b/packages/backend/src/server/api/endpoints/clips/delete.ts
index cf3365e1a7c905cee1848004ae7b1030aca300f0..239945e8a4a04854bd50dca06c09fc3a3fa3311c 100644
--- a/packages/backend/src/server/api/endpoints/clips/delete.ts
+++ b/packages/backend/src/server/api/endpoints/clips/delete.ts
@@ -5,8 +5,7 @@
 
 import { Inject, Injectable } from '@nestjs/common';
 import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { ClipsRepository } from '@/models/_.js';
-import { DI } from '@/di-symbols.js';
+import { ClipService } from '@/core/ClipService.js';
 import { ApiError } from '../../error.js';
 
 export const meta = {
@@ -36,20 +35,17 @@ export const paramDef = {
 @Injectable()
 export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
 	constructor(
-		@Inject(DI.clipsRepository)
-		private clipsRepository: ClipsRepository,
+		private clipService: ClipService,
 	) {
 		super(meta, paramDef, async (ps, me) => {
-			const clip = await this.clipsRepository.findOneBy({
-				id: ps.clipId,
-				userId: me.id,
-			});
-
-			if (clip == null) {
-				throw new ApiError(meta.errors.noSuchClip);
+			try {
+				await this.clipService.delete(me, ps.clipId);
+			} catch (e) {
+				if (e instanceof ClipService.NoSuchClipError) {
+					throw new ApiError(meta.errors.noSuchClip);
+				}
+				throw e;
 			}
-
-			await this.clipsRepository.delete(clip.id);
 		});
 	}
 }
diff --git a/packages/backend/src/server/api/endpoints/clips/remove-note.ts b/packages/backend/src/server/api/endpoints/clips/remove-note.ts
index 28a2f8ebd525977fd3c9879f0b72e3c1432ad6cd..d84a57cac04af22fa7483ae69cfe1dfb7551fa99 100644
--- a/packages/backend/src/server/api/endpoints/clips/remove-note.ts
+++ b/packages/backend/src/server/api/endpoints/clips/remove-note.ts
@@ -5,9 +5,7 @@
 
 import { Inject, Injectable } from '@nestjs/common';
 import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { ClipNotesRepository, ClipsRepository, NotesRepository } from '@/models/_.js';
-import { DI } from '@/di-symbols.js';
-import { GetterService } from '@/server/api/GetterService.js';
+import { ClipService } from '@/core/ClipService.js';
 import { ApiError } from '../../error.js';
 
 export const meta = {
@@ -46,38 +44,17 @@ export const paramDef = {
 @Injectable()
 export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
 	constructor(
-		@Inject(DI.clipsRepository)
-		private clipsRepository: ClipsRepository,
-
-		@Inject(DI.clipNotesRepository)
-		private clipNotesRepository: ClipNotesRepository,
-
-		@Inject(DI.notesRepository)
-		private notesRepository: NotesRepository,
-
-		private getterService: GetterService,
+		private clipService: ClipService,
 	) {
 		super(meta, paramDef, async (ps, me) => {
-			const clip = await this.clipsRepository.findOneBy({
-				id: ps.clipId,
-				userId: me.id,
-			});
-
-			if (clip == null) {
-				throw new ApiError(meta.errors.noSuchClip);
+			try {
+				await this.clipService.removeNote(me, ps.clipId, ps.noteId);
+			} catch (e) {
+				if (e instanceof ClipService.NoSuchClipError) {
+					throw new ApiError(meta.errors.noSuchClip);
+				}
+				throw e;
 			}
-
-			const note = await this.getterService.getNote(ps.noteId).catch(err => {
-				if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
-				throw err;
-			});
-
-			await this.clipNotesRepository.delete({
-				noteId: note.id,
-				clipId: clip.id,
-			});
-
-			this.notesRepository.decrement({ id: note.id }, 'clippedCount', 1);
 		});
 	}
 }
diff --git a/packages/backend/src/server/api/endpoints/clips/update.ts b/packages/backend/src/server/api/endpoints/clips/update.ts
index 7dda865609f0e0a4b1ea5e2f620f6384a9bf24f7..0b9878578cd8a44ebdc149680678f16080e50aef 100644
--- a/packages/backend/src/server/api/endpoints/clips/update.ts
+++ b/packages/backend/src/server/api/endpoints/clips/update.ts
@@ -5,9 +5,8 @@
 
 import { Inject, Injectable } from '@nestjs/common';
 import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { ClipsRepository } from '@/models/_.js';
 import { ClipEntityService } from '@/core/entities/ClipEntityService.js';
-import { DI } from '@/di-symbols.js';
+import { ClipService } from '@/core/ClipService.js';
 import { ApiError } from '../../error.js';
 
 export const meta = {
@@ -48,29 +47,21 @@ export const paramDef = {
 @Injectable()
 export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
 	constructor(
-		@Inject(DI.clipsRepository)
-		private clipsRepository: ClipsRepository,
+		private clipService: ClipService,
 
 		private clipEntityService: ClipEntityService,
 	) {
 		super(meta, paramDef, async (ps, me) => {
-			// Fetch the clip
-			const clip = await this.clipsRepository.findOneBy({
-				id: ps.clipId,
-				userId: me.id,
-			});
-
-			if (clip == null) {
-				throw new ApiError(meta.errors.noSuchClip);
+			try {
+				await this.clipService.update(me, ps.clipId, ps.name, ps.isPublic, ps.description);
+			} catch (e) {
+				if (e instanceof ClipService.NoSuchClipError) {
+					throw new ApiError(meta.errors.noSuchClip);
+				}
+				throw e;
 			}
 
-			await this.clipsRepository.update(clip.id, {
-				name: ps.name,
-				description: ps.description,
-				isPublic: ps.isPublic,
-			});
-
-			return await this.clipEntityService.pack(clip.id, me);
+			return await this.clipEntityService.pack(ps.clipId, me);
 		});
 	}
 }