Resource identification

To identify a resource from a public interface:

  • Use GUIDs
  • Use slugs (lowercase version of the name with hyphens replacing spaces)
  • Do not use sequential integers

Slugs are an ideal choice for items such as locations, resource types where the guessability of the name is not a problem and in many cases can be useful.

GET /api/locations/london/details

Instead of:

GET /api/locations/1/details

Use GUIDs for any resource where it is important to prevent people from easily changing the number in order to find another instance. Examples would include any user identifier and transaction Ids.

GET /api/user/511AC810-C968-4CE0-8588-01125B551088/details

Sequential integers still have significant performance benefits as the PK/FK on SQL table joins so should be used internally, but they pose a security weakness if used for external resource identification.

Use an abstract ‘Entity’ class

One way to enforce this is to use a base class to setup all Entities in the same way.

// This uses a primary constructor, available from .Net8
public abstract class Entity<T>(T id) where T : ValueObject
{
    public T Id { get; protected set; } = id;
    public bool IsActive { get; protected set; } = true;
    public bool IsDeleted { get; protected set; } = false;    
}

This uses a strongly typed ID to avoid problems with the order being confused in methods referencing multiple IDs.

When you want to support a slug for the entity, then add another abstract class which inherits from this one:

public abstract class EntityWithSlug<T> : Entity<T> where T : ValueObject
{
    protected EntityWithSlug(T id, string name) : base(id)
    {
        Name = name;
        if (!string.IsNullOrWhiteSpace(name))
        {
            var slugResult = Slug.Create(name);
            Slug = slugResult.IsSuccess ? slugResult.Value : null;
        }
    }
    [MaxLength(50)]
    public string Name { get; protected set; }
    public Slug? Slug { get; protected set; }
}

When used in a class, the ID needs to be passed to the base class:

public sealed class Booking : Entity<BookingId>
{
    private Booking(BookingId id) : base(id)
    {
        Status = BookingStatus.Provisional;
        Source = BookingSource.CallHandler;
    }
    public Appointment? Appointment { get; private set; }
    public BookingStatus Status { get; private set; }
    public BookingSource Source { get; private set; }
    public DateTime Created { get; private set; } = DateTime.UtcNow;
    // other properties and methods

BookingId is created as a value object. Note the domain object for Booking doesn’t need to hold the ID of the Appointment, it just has the navigation property to appointment.

Configuration for Entity Framework

internal class BookingConfiguration : IEntityTypeConfiguration<Booking>
{
    public void Configure(EntityTypeBuilder<Booking> builder)
    {
        builder.ToTable("Bookings");
        // Creates the integer primary key
        builder.Property<int>("BookingId").IsRequired();
        builder.HasKey("BookingId");

        // Tells entity how to retrieve and set the value for the ID
        builder.Property(b => b.Id)
            .HasConversion(b => b.Value, b => BookingId.Create(b));

        // Sets up the navigation to the Appointment
        builder.Navigation(b => b.Appointment)
            .UsePropertyAccessMode(PropertyAccessMode.Property)
            .AutoInclude();
    }
}