Domain-Driven Design in .NET: A Practical Guide with Hotel Booking Example
Learn how to apply Domain-Driven Design (DDD) in .NET with real-world hotel booking examples. Discover key concepts like entities, value objects, aggregates, repositories, and services, and structure your application for better testability and scalability.
Domain-Driven Design (DDD) is a powerful architectural approach to building maintainable, testable, and business-aligned applications, especially when working with complex domains.
In this article, we’ll walk through the core DDD concepts and show how to implement them in a real-world hotel booking context using .NET.
What Is Domain-Driven Design?
DDD approach, introduced by Eric Evans (2004), emphasizes aligning software models closely with business concepts. It helps teams:
- Focus on core business logic
- Create rich domain models
- Separate infrastructure concerns
- Speak a Ubiquitous Language shared by both developers and domain experts
Suggested Architecture
Here’s how to organize our solution:
/Domain
├── Entities
├── ValueObjects
├── Interfaces
└── DomainServices
/Application
└── UseCases / AppServices
/Infrastructure
├── Repositories
└── ExternalServices
/WebApi
└── Controllers
Ubiquitous Language in Action
With DDD, both developers and business experts use the same terms:
“Hotel”, “Availability”, “Booking”, “Check-in” , these are reflected directly in code.
This makes your code not just technical, but communicative and understandable.
Core DDD Building Blocks
Let’s explore the key components of DDD using a hotel booking scenario.
1. Entity
An entity has a unique identity and changes over time.
Hotel
public class Hotel
{
public Guid Id { get; }
public string Name { get; }
public Address Address { get; }
private readonly List<Booking> _bookings = new();
public Hotel(Guid id, string name, Address address)
{
Id = id;
Name = name;
Address = address;
}
public bool IsAvailable(DateTime checkIn, DateTime checkOut)
{
return !_bookings.Any(b => (checkIn < b.CheckOut) && (checkOut > b.CheckIn));
}
public Guid Book(DateTime checkIn, DateTime checkOut)
{
if (!IsAvailable(checkIn, checkOut))
throw new InvalidOperationException("Hotel is not available for the selected dates.");
var booking = new Booking(this.Id, checkIn, checkOut);
_bookings.Add(booking);
return booking.Id;
}
public BookingStatus GetBookingStatus(Guid bookingId)
{
var booking = _bookings.FirstOrDefault(b => b.Id == bookingId);
if (booking == null)
return BookingStatus.NotFound;
return booking.Status;
}
}
Booking
public class Booking
{
public Guid Id { get; }
public Guid HotelId { get; set;}
public DateTime CheckIn { get; }
public DateTime CheckOut { get; }
public BookingStatus Status { get; private set; }
public Booking(Guid hotelId, DateTime checkIn, DateTime checkOut)
{
if (checkIn >= checkOut)
throw new ArgumentException("Check-out must be after check-in.");
Id = Guid.NewGuid();
HotelId = hotelId;
CheckIn = checkIn;
CheckOut = checkOut;
Status = BookingStatus.Confirmed;
}
}
public enum BookingStatus
{
Confirmed,
Cancelled,
Completed,
NotFound
}
2. Value Object
A value object has no identity and is compared by its properties.
HotelLocation
public class HotelLocation
{
public string City { get; }
public string Country { get; }
public string Region { get; }
public double Latitude { get; }
public double Longitude { get; }
public HotelLocation(string city, string country, string region, double latitude, double longitude)
{
City = city;
Country = country;
Region = region;
Latitude = latitude;
Longitude = longitude;
}
public override bool Equals(object? obj) =>
obj is HotelLocation other &&
City == other.City &&
Country == other.Country &&
Region == other.Region &&
Latitude == other.Latitude &&
Longitude == other.Longitude;
public override int GetHashCode() =>
HashCode.Combine(City, Country, Region, Latitude, Longitude);
}
3. Aggregate Root
Aggregates group related entities and ensure consistency boundaries. Access and modify data only through the aggregate root.
HotelSearch
public class HotelSearch
{
private readonly List<Hotel> _hotels = new();
public IReadOnlyCollection<Hotel> Results => _hotels;
public void AddHotel(Hotel hotel)
{
if (_hotels.Any(h => h.Id == hotel.Id)) return;
_hotels.Add(hotel);
}
public Hotel? FindAvailableHotel(Guid hotelId, DateTime checkIn, DateTime checkOut)
{
var hotel = _hotels.FirstOrDefault(h => h.Id == hotelId); // .. search logic
return hotel?.IsAvailable(checkIn, checkOut) == true ? hotel : null;
}
}
4. Repository Interface
Repositories abstract data access and operate only on aggregate roots.
public interface IHotelRepository
{
Hotel? GetById(Guid id);
void Save(Hotel hotel);
}
5. Domain Service
Use domain services when logic spans across multiple entities and doesn’t naturally fit within one.
HotelBookingService
public class HotelBookingService
{
private readonly IHotelRepository _repository;
public HotelBookingService(IHotelRepository repository)
{
_repository = repository;
}
public Guid BookHotel(Guid hotelId, DateTime checkIn, DateTime checkOut)
{
var hotel = _repository.GetById(hotelId);
if (hotel == null)
throw new InvalidOperationException("Hotel not found.");
var bookingId = hotel.Book(checkIn, checkOut);
_repository.Save(hotel);
return bookingId;
}
public BookingStatus GetBookingStatus(Guid hotelId, Guid bookingId)
{
var hotel = _repository.GetById(hotelId);
return hotel?.GetBookingStatus(bookingId) ?? BookingStatus.NotFound;
}
}
6. Application Service
Coordinates domain operations, serving as an entry point for external interfaces (like APIs).
HotelAppService
public class HotelAppService
{
private readonly HotelBookingService _bookingService;
private readonly HotelSearch _hotelSearch;
public HotelAppService(HotelBookingService bookingService, HotelSearch hotelSearch)
{
_bookingService = bookingService;
_hotelSearch = hotelSearch;
}
public Guid MakeBooking(Guid hotelId, DateTime checkIn, DateTime checkOut)
{
return _bookingService.BookHotel(hotelId, checkIn, checkOut);
}
public BookingStatus QueryBookingStatus(Guid hotelId, Guid bookingId)
{
return _bookingService.GetBookingStatus(hotelId, bookingId);
}
public IReadOnlyCollection<Hotel> SearchAvailability(DateTime checkIn, DateTime checkOut)
{
return _hotelSearch
.Results
.Where(h => h.IsAvailable(checkIn, checkOut))
.ToList();
}
}
Unit Test Example
With proper boundaries and clear responsibilities, testing becomes painless.
[Fact]
public void Hotel_Should_Return_Correct_Booking_Status()
{
var location = new HotelLocation("Antalya", "Turkiye", "Belek", 37.6050, 39.9633);
var hotel = new Hotel(Guid.NewGuid(), "SandBeachX Resort", location);
var bookingId = hotel.Book(DateTime.Today.AddDays(1), DateTime.Today.AddDays(3));
var status = hotel.GetBookingStatus(bookingId);
Assert.Equal(BookingStatus.Confirmed, status);
}
We’ve modeled a hotel booking domain using Domain-Driven Design principles in .NET.
Key takeaways:
- Use Value Objects (like
HotelLocation
) for rich, immutable data. - Keep Entities like
Hotel
andBooking
focused on business rules. - Delegate workflows like availability and booking to Application Services.
- Use Domain Services for coordinating behavior that spans multiple entities.
This layered approach helps build systems that are expressive, testable, and easy to evolve.