ADO.NET Entity Framework goes DDD und TDD

Nachdem die Entwicklergemeinde das Entity Framework mit einer Vote of No Confidence gewürdigt und damit deutlich gemacht hat, dass der Persistensbaukausten in die falsche Richtung läuft, hat Microsoft nachgebessert. Plain old CLR Objects (POCO) werden nun unterstützt, was TDD ermöglicht. Das Datenbankschema allerdings bleibt Grundlage für eine EF-Anwendung. Mich hat es gereizt, eine Beispielanwendung zu schreiben. Ich finde den Gedanken faszinierend, eine Datenbankanwendung modell- und testgetrieben zu entwickeln und gleichzeitig von der Persistenzschicht zu abstrahieren, indem ich das Framework die lästigen Routinearbeiten erledigen lasse.

Nun habe ich denn mit einem Datenbankschema begonnen, welches nachfolgend abgebildet ist. Die Entitäten Person und Booking wurden mit Visual Studio modelliert. Eine Person kann mehrere Buchungen eines Ferienhauses machen. Die beiden Entitäten Person und Booking haben jeweils einen Primärschlüssel, ihre IDs. Booking hat einen Sekundärschlüssel, die ID der Person. Dieses Modell sorgt für Konsistenz. Die Datenbank erlaubt es fortan nicht, dass eine Person, auf die eine Buchung verweist, gelöscht oder deren ID verändert wird. Aus diesem Datenbankschema erzeuge ich SQL-Code, um meine Datenbank mit Tabellen zu füllen.

generate_database

Wie man in der folgenden Abbildung sieht, teilt die Solution Anwendung, Modell, Persistenz und Tests in seperate Projekte auf. Das Modell-Projekt hat keine Referenz auf andere Projekte, sondern wird von allen Projekten benutzt. Das habe ich der POCO-Unterstützung zu verdanken. Mit EF4 ist es möglich, mit Objekten zu arbeiten, die die Persistenz ignorieren.

solution_model

Mit unabhängigen POCO wird die Verantwortung allerdings dem Modell übertragen, dass nun selber für Konsistenz sorgen muss. Die Setter Methode Personder Klasse Booking, muss _personID im folgenden Code auffrischen. War _personID vorher ein anderes Objekt, muss das vorherige Objektthis aus der Liste der Buchungen herausnehmen. _personID muss hingegen this zur Liste der Buchungen hinzufügen.

namespace Model
{
    public partial class Booking : IValidate
    {

        #region Primitive Properties

        public virtual int ID { get; set; }
        public virtual DateTime StartDate { get; set; }
        public virtual DateTime EndDate { get; set; }

        public virtual int PersonID
        {
            get { return _personID; }
            set
            {
                if (value != _personID)
                {
                    if (_person != null && _person.ID != value)
                    {
                        Person = null;
                    }

                    _personID = value;
                }
            }
        }
        private int _personID;

        #endregion

        #region Navigation Properties

        public virtual Person Person
        {
            get
            {
                return _person;
            }
            set
            {
                if (!Object.ReferenceEquals(_person, value))
                {
                    var existingValue = _person;

                    _person = value;

                    if (value != null)
                    {
                        _personID = value.ID;
                    }
                    if ((existingValue != null) && (existingValue.Bookings.Contains(this)))
                    {
                        existingValue.Bookings.Remove(this);
                    }

                    if ((value != null) && !value.Bookings.Contains(this))
                    {
                        value.Bookings.Add(this);
                    }
                }
            }
        }

        private Person _person;

        #endregion

        #region IValidate Members
        void IValidate.Validate(ChangeAction action)
        {
            if (action == ChangeAction.Insert)
            {
                if (this.Person == null)
                    return;

                if (this.Person.Bookings.Count(c => c.StartDate == this.StartDate ) > 1)
                    throw new InvalidOperationException(
                "The booking overlaps with another booking of this person");
            }
        }

        #endregion
    }
}

Andererseits fügt Person im folgenden Code im Konstruktor der Liste von Buchungen einen Delegaten hinzu, der aufgerufen wird, wenn eine Buchung hinzugefügt oder entfernt wird. Einer neu hinzugefügten Buchung wird this zugeordnet, einer entfernten Buchung wird this entzogen.

namespace Model
{
    public partial class Person : IValidate
    {
        public Person()
        {
            _bookings = new ObservableCollection<Booking>();
            ((ObservableCollection<Booking>)_bookings).CollectionChanged += (sender, e) =>
            {
                if (e.NewItems != null)
                    foreach (Booking item in e.NewItems)
                        if (item.Person != this)
                            item.Person = this;

                if (e.OldItems != null)
                    foreach (Booking item in e.OldItems)
                        if (item.Person == this)
                            item.Person = null;
            };

        }

        public virtual int ID { get; set; }
        public virtual string FirstName { get; set; }
        public virtual string LastName { get; set; }

        #region Navigation Properties

        public virtual ICollection<Booking> Bookings
        {
            get
            {
                return _bookings;
            }
        }

        private  ICollection<Booking> _bookings;
        #endregion

        #region IValidate Members

        public void Validate(ChangeAction action)
        {
        }

        #endregion
    }

}

Als nächstes muss der Datenbankzugriff gekapselt werden, um vollständige Testbarkeit zu gewährleisten. Das erledigt man überlichweise mit einem Repository. Da ich testgetrieben entwickeln will, abeitet mein Repository auf einem austauschbaren Context. Für die Tests wird ein Fakekontext benutzt, für den Produktionscode der direkte Datenbankzugriff. Der Context ist eine Implementierung des nachfolgenden Interface IEntities:

namespace Model
{
    public interface IEntities : IDisposable
    {
        IObjectSet<Person> Persons { get; }
        IObjectSet<Booking> Bookings { get; }
        int SaveChanges();

    }
}

Hier die Klasse Repository:

namespace Model
{
    public class Repository
    {
        private IEntities _context;

        public Repository(IEntities context)
        {
            if (context == null)
                throw new ArgumentNullException("context was null");
            _context = context;
        }

        public void AddBooking(Booking b)
        {
            _context.Bookings.AddObject(b);
        }

        public void AddPerson(Person p)
        {
            _context.Persons.AddObject(p);
        }

        public Person GetPersonByID(int id)
        {
            return _context.Persons.First(p => p.ID == id);
        }

        public void SaveChanges()
        {
            _context.SaveChanges();
        }
    }
}

Wie der Kontext fake- und datenbankseitig implementiert wird, verschweige ich hier mal. Die Schablone dafür habe ich mir von diesem Blog abgeschaut.

Ein Beispieltest beweist mir, dass eine Exception geschmissen wird, wenn eine Person 2 Buchungen macht, deren Startdatum gleich sind:

namespace Tests
{
    [TestClass]
    public class CommentTests
    {
        private IEntities _context;

        [TestInitialize]
        public void TestSetup()
        {
            Person p = new Person()
            {
                FirstName = "Jonathan",
                LastName = "Aneja",
                ID = 1
            };

            Booking b = new Booking()
            {
                StartDate = new DateTime(2010, 10, 1),
                EndDate = new DateTime(2010, 10, 8),
                Person = p
            };

            _context = new FakeEntities();
            var repository = new Repository(_context);

            repository.AddPerson(p);
            repository.SaveChanges();
        }

        [TestMethod]
        [ExpectedException(typeof(InvalidOperationException))]
        public void AttemptedOverlappingBookings()
        {
            var repository = new Repository(_context);
            Booking b2 = new Booking()
            {
                StartDate = new DateTime(2010, 10, 1),
                EndDate = new DateTime(2010, 10, 8),
                Person = repository.GetPersonByID(1)
            };

            repository.AddBooking(b2);
            repository.SaveChanges();

        }
    }
}

Hier die Frucht des Ganzen: ein wunderschöner objektorientierter Code, um einen Benutzer und dessen Buchung in der Datenbank anzulegen:

namespace Application
{
    class Program
    {
        static void Main(string[] args)
        {
            Person p1 = new Person()
            {
                FirstName = "aaaaa",
                LastName = "bbbb"
            };

            Booking b = new Booking()
            {
                StartDate = new DateTime(2013, 10, 1),
                EndDate = new DateTime(2013, 10, 8),
                Person = p1
            };

            var _context = new VacationHomeBookingEntities();
            var repository = new Repository(_context);

            repository.AddBooking(b);

            try
            {
                repository.SaveChanges();
            }
            catch (InvalidOperationException e)
            {
                Console.WriteLine("{0} Exception caught.", e);
                Console.ReadKey();

                return;
            }

        }
    }
}
 

Hinterlassen Sie eine Antwort