diff --git a/SVSim.Database/Services/Friend/FriendDtos.cs b/SVSim.Database/Services/Friend/FriendDtos.cs
new file mode 100644
index 0000000..ec30137
--- /dev/null
+++ b/SVSim.Database/Services/Friend/FriendDtos.cs
@@ -0,0 +1,84 @@
+namespace SVSim.Database.Services.Friend;
+
+///
+/// One friend in the requested viewer's friend list. Wire shape carries 15 fields;
+/// most are cosmetic ints emitted as strings (matches prod). Numeric fields
+/// (viewer_id, rank, emblem_id, degree_id) ship as native ints.
+///
+public sealed record FriendEntry(
+ int ViewerId,
+ string Name,
+ string CountryCode,
+ int Rank,
+ long EmblemId,
+ int DegreeId,
+ string LastPlayTime, // "yyyy-MM-dd HH:mm:ss"
+ string DeviceType,
+ string MaxFriend,
+ string IsReceivedTwoPickMission,
+ string Birth,
+ string MissionChangeTime,
+ string MissionReceiveType,
+ string IsOfficial,
+ string IsOfficialMarkDisplayed);
+
+///
+/// One friend apply (sent or received). Wire field id is the apply's PK.
+///
+public sealed record FriendApplyEntry(
+ int Id,
+ int ViewerId, // OTHER viewer's id
+ string Name,
+ string CountryCode,
+ int Rank,
+ long EmblemId,
+ int DegreeId,
+ string LastPlayTime,
+ string CreateTime,
+ int MissionType); // 0 when omitted on the wire
+
+///
+/// One recent-opponent row. is computed at read time:
+/// 0 = NO_ACTION, 1 = IS_FRIEND, 2 = IS_SEND (caller has outgoing apply),
+/// 3 = IS_RECEIVED (caller has incoming apply from opponent).
+/// is the relevant apply's PK when status is 2 or 3, else 0.
+///
+public sealed record PlayedTogetherEntry(
+ int ViewerId,
+ string Name,
+ string CountryCode,
+ int Rank,
+ long EmblemId,
+ int DegreeId,
+ string LastPlayTime,
+ string PlayedTime,
+ int FriendStatus,
+ int FriendApplyId,
+ int PlayedMode,
+ int BattleType,
+ int DeckFormat,
+ int TwoPickType);
+
+public sealed record FriendInfoResult(
+ IReadOnlyList Friends,
+ int Count,
+ int MaxCount);
+
+public sealed record ReceiveApplyInfoResult(
+ IReadOnlyList ReceiveApplies,
+ int ApproveApplyCount);
+
+public sealed record SendApplyInfoResult(
+ IReadOnlyList SendApplies,
+ int RemainingApplyCount,
+ int SendApplyMaxCount);
+
+public sealed record PlayedTogetherResult(
+ IReadOnlyList Histories);
+
+/// Context recorded by .
+public sealed record BattleParticipationContext(
+ int PlayedMode,
+ int BattleType,
+ int DeckFormat,
+ int TwoPickType);
diff --git a/SVSim.Database/Services/Friend/FriendService.cs b/SVSim.Database/Services/Friend/FriendService.cs
new file mode 100644
index 0000000..c235de1
--- /dev/null
+++ b/SVSim.Database/Services/Friend/FriendService.cs
@@ -0,0 +1,69 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace SVSim.Database.Services.Friend;
+
+public sealed class FriendService : IFriendService, IPlayedTogetherWriter
+{
+ internal const int FriendMaxCount = 110;
+ internal const int SendApplyMaxCount = 110;
+ internal const int PlayedTogetherRetention = 50;
+
+ // Cosmetic field defaults matching the prod capture's "no campaign, normal player" state.
+ internal const string DefaultDeviceType = "2";
+ internal const string DefaultMaxFriend = "110";
+ internal const string DefaultIsReceivedTwoPickMission = "1";
+ internal const string DefaultBirth = "0";
+ internal const string DefaultMissionChangeTime = "2017-09-15 02:36:09";
+ internal const string DefaultMissionReceiveType = "0";
+ internal const string DefaultIsOfficial = "0";
+ internal const string DefaultIsOfficialMarkDisplayed = "0";
+
+ private readonly SVSimDbContext _db;
+ private readonly ILogger _log;
+
+ public FriendService(SVSimDbContext db, ILogger log)
+ {
+ _db = db;
+ _log = log;
+ }
+
+ public Task GetFriendsAsync(long viewerId, CancellationToken ct) =>
+ throw new NotImplementedException();
+
+ public Task GetReceiveAppliesAsync(long viewerId, CancellationToken ct) =>
+ throw new NotImplementedException();
+
+ public Task GetSendAppliesAsync(long viewerId, CancellationToken ct) =>
+ throw new NotImplementedException();
+
+ public Task GetPlayedTogetherAsync(long viewerId, CancellationToken ct) =>
+ throw new NotImplementedException();
+
+ public Task SearchAsync(long viewerId, int targetViewerId, CancellationToken ct) =>
+ throw new NotImplementedException();
+
+ public Task SendApplyAsync(long viewerId, int targetViewerId, CancellationToken ct) =>
+ throw new NotImplementedException();
+
+ public Task ApproveApplyAsync(long viewerId, int applyId, CancellationToken ct) =>
+ throw new NotImplementedException();
+
+ public Task RejectApplyAsync(long viewerId, int applyId, CancellationToken ct) =>
+ throw new NotImplementedException();
+
+ public Task CancelApplyAsync(long viewerId, int applyId, CancellationToken ct) =>
+ throw new NotImplementedException();
+
+ public Task RejectAllAppliesAsync(long viewerId, CancellationToken ct) =>
+ throw new NotImplementedException();
+
+ public Task CancelAllAppliesAsync(long viewerId, CancellationToken ct) =>
+ throw new NotImplementedException();
+
+ public Task RejectFriendAsync(long viewerId, int targetViewerId, CancellationToken ct) =>
+ throw new NotImplementedException();
+
+ public Task RecordAsync(long ownerViewerId, long opponentViewerId, BattleParticipationContext ctx, CancellationToken ct) =>
+ throw new NotImplementedException();
+}
diff --git a/SVSim.Database/Services/Friend/IFriendService.cs b/SVSim.Database/Services/Friend/IFriendService.cs
new file mode 100644
index 0000000..eafae28
--- /dev/null
+++ b/SVSim.Database/Services/Friend/IFriendService.cs
@@ -0,0 +1,30 @@
+namespace SVSim.Database.Services.Friend;
+
+public interface IFriendService
+{
+ Task GetFriendsAsync(long viewerId, CancellationToken ct);
+ Task GetReceiveAppliesAsync(long viewerId, CancellationToken ct);
+ Task GetSendAppliesAsync(long viewerId, CancellationToken ct);
+ Task GetPlayedTogetherAsync(long viewerId, CancellationToken ct);
+
+ /// Returns null when not found, self-search, or any error.
+ Task SearchAsync(long viewerId, int targetViewerId, CancellationToken ct);
+
+ /// No-op if target missing, self, already friends, already-pending apply, or at outgoing-apply cap.
+ Task SendApplyAsync(long viewerId, int targetViewerId, CancellationToken ct);
+
+ /// No-op if apply not addressed to caller, would push either side past friend cap. Cleans reverse-direction apply if present.
+ Task ApproveApplyAsync(long viewerId, int applyId, CancellationToken ct);
+
+ /// No-op if apply not addressed to caller.
+ Task RejectApplyAsync(long viewerId, int applyId, CancellationToken ct);
+
+ /// No-op if apply not sent by caller.
+ Task CancelApplyAsync(long viewerId, int applyId, CancellationToken ct);
+
+ Task RejectAllAppliesAsync(long viewerId, CancellationToken ct);
+ Task CancelAllAppliesAsync(long viewerId, CancellationToken ct);
+
+ /// Deletes both directions of the friendship (A→B and B→A).
+ Task RejectFriendAsync(long viewerId, int targetViewerId, CancellationToken ct);
+}
diff --git a/SVSim.Database/Services/Friend/IPlayedTogetherWriter.cs b/SVSim.Database/Services/Friend/IPlayedTogetherWriter.cs
new file mode 100644
index 0000000..8a56465
--- /dev/null
+++ b/SVSim.Database/Services/Friend/IPlayedTogetherWriter.cs
@@ -0,0 +1,11 @@
+namespace SVSim.Database.Services.Friend;
+
+///
+/// Records a recent-opponent entry on the owner viewer. Upserts the (owner, opponent)
+/// row to PlayedAt = now, enforces a 50-row per-viewer retention cap by deleting the
+/// owner's oldest row when at cap. No-op if owner equals opponent.
+///
+public interface IPlayedTogetherWriter
+{
+ Task RecordAsync(long ownerViewerId, long opponentViewerId, BattleParticipationContext ctx, CancellationToken ct);
+}