diff --git a/src/StevanFreeborn.Atproto.IdentityResolver/AtprotoId.cs b/src/StevanFreeborn.Atproto.IdentityResolver/AtprotoId.cs
new file mode 100644
index 0000000..a7dd472
--- /dev/null
+++ b/src/StevanFreeborn.Atproto.IdentityResolver/AtprotoId.cs
@@ -0,0 +1,132 @@
+namespace StevanFreeborn.Atproto.IdentityResolver;
+
+///
+///
+///
+public abstract class AtprotoId
+{
+ ///
+ ///
+ ///
+ public string Value { get; protected set; } = string.Empty;
+
+ ///
+ ///
+ ///
+ public static AtprotoId From(string atprotoId)
+ {
+ if (string.IsNullOrEmpty(atprotoId))
+ {
+ throw new ArgumentException("Cannot be null or empty", nameof(atprotoId));
+ }
+
+ if (IsDomain(atprotoId))
+ {
+ return Handle.From(atprotoId);
+ }
+
+ return Did.From(atprotoId);
+ }
+
+ private static bool IsDomain(string did)
+ {
+ var hostType = Uri.CheckHostName(did);
+ return hostType == UriHostNameType.Dns && did.Contains('.', StringComparison.OrdinalIgnoreCase);
+ }
+
+}
+
+///
+///
+///
+public class Did : AtprotoId
+{
+ private static readonly string[] ValidPrefixes = ["did:web:", "did:plc:"];
+
+ ///
+ ///
+ ///
+ public string Method { get; private set; }
+
+ private Did(string did)
+ {
+ if (string.IsNullOrEmpty(did))
+ {
+ throw new ArgumentException("Cannot be null or empty", nameof(did));
+ }
+
+ if (TryParseWithoutPrefix(did, out var prefix, out var didWithoutPrefix) is false)
+ {
+ throw new ArgumentException($"Must be did with one of these methods: {string.Join(',', ValidPrefixes)}", nameof(did));
+ }
+
+ if (string.IsNullOrEmpty(didWithoutPrefix))
+ {
+ throw new ArgumentException("Must not be null or empty without did method prefix", nameof(did));
+ }
+
+ // Method should probably be a strongly
+ // typed object
+ Method = prefix.Contains("web", StringComparison.OrdinalIgnoreCase) ? "web" : "plc";
+ Value = did;
+ }
+
+ ///
+ ///
+ ///
+ public static new Did From(string did)
+ {
+ return new(did);
+ }
+
+ private static bool TryParseWithoutPrefix(
+ string did,
+ out string prefix,
+ out string didWithoutPrefix
+ )
+ {
+ prefix = string.Empty;
+ didWithoutPrefix = string.Empty;
+
+ foreach (var validPrefix in ValidPrefixes)
+ {
+ if (did.StartsWith(validPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ prefix = did[..validPrefix.Length];
+ didWithoutPrefix = did[validPrefix.Length..];
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
+
+///
+///
+///
+public class Handle : AtprotoId
+{
+ private Handle(string handle)
+ {
+ if (string.IsNullOrEmpty(handle))
+ {
+ throw new ArgumentException("Cannot be null or empty", nameof(handle));
+ }
+
+ if (handle.StartsWith('@'))
+ {
+ throw new ArgumentException("Cannot start with @", nameof(handle));
+ }
+
+ Value = handle;
+ }
+
+ ///
+ ///
+ ///
+ public static new Handle From(string handle)
+ {
+ return new(handle);
+ }
+}
\ No newline at end of file
diff --git a/tests/StevanFreeborn.Atproto.IdentityResolver.Tests/AtprotoIdTests.cs b/tests/StevanFreeborn.Atproto.IdentityResolver.Tests/AtprotoIdTests.cs
new file mode 100644
index 0000000..3cf3cf6
--- /dev/null
+++ b/tests/StevanFreeborn.Atproto.IdentityResolver.Tests/AtprotoIdTests.cs
@@ -0,0 +1,19 @@
+namespace StevanFreeborn.Atproto.IdentityResolver.Tests;
+
+public class AtprotoIdTests
+{
+ [Theory]
+ [InlineData("")]
+ [InlineData("localhost")]
+ [InlineData("did:web:")]
+ [InlineData("did:plc:")]
+ [InlineData("did:mu:dxbmien5xsrkov5yhywgc4zu")]
+ [InlineData("@stevanfreeborn.com")]
+ [InlineData(null)]
+ public void From_WhenCalledWithInvalidDid_ItShouldThrowInvalidArgumentException(string? atprotoId)
+ {
+ var act = () => AtprotoId.From(atprotoId!);
+
+ act.Should().Throw();
+ }
+}
\ No newline at end of file