cult3

Creating Domain Objects Recap

Dec 08, 2014

Table of contents:

  1. What are we going to build?
  2. Group ID Value Object
  3. Name and Slug Value Object
  4. Group Entity
  5. Group Repository
  6. Name is Unique Specification
  7. Group Doctrine ORM Repository
  8. Conclusion

One of the central aspects of Domain Driven Design is how Domain Objects attract responsibility to encapsulate the business rules of the application.

By encapsulating business logic internally to an object, the consumer of the object does not need to be concerned with how that logic is implemented.

For example, we could have an Email object that defines what we accept as a valid email address. Any other part of our application that uses the Email object does not need to be concerned with ensuring that an email address is valid because the Email object already has it covered.

So far in this series we’ve built out a lot of functionality in the Identity Bounded Context. Last week we looked at moving our attention to a new Bounded Context of Groups.

In order to lay the foundation for the Groups Bounded Context we’re going to need to write a couple of Domain Objects. These Domain Objects will be very similar to what we’ve already implemented for the Identity namespace because patterns like Value Objects, Entities and Specifications are universally applicable to a lot of projects.

The focus of this week’s tutorial will be to build out this foundational layer of the Groups Bounded Context and to take a world wind recap of some of the things we’ve covered over the last couple of weeks.

Repetition is one of the most important aspects of learning and reaching the enlightenment of understanding, so hopefully if you’ve read the previous posts, a lot of these concepts will be familiar or will fall into place when you see them in a different light.

What are we going to build?

Before I jump into the code, first we’ll do a quick review of the objects we’re going to need to write in order to lay the foundation for the Groups Bounded Context.

Firstly we’re going to need the Group Entity. This object will be the main focal point of this Bounded Context. The Group Entity will manage the lifecycle of the Group as well as the members and admin associations.

Each Group Entity will require a GroupId because we’re using UUIDs instead of auto-incrementing ids in this application.

We’re going to have to restrict the names of Groups to certain characters, and we’ll need to be able to generate slugs to use in URLs. We therefore need to have Name and Slug Value Objects.

Each Group in the application will need to have a unique name so that each Group can be found via URL. We can ensure that Group names are unique by using a NameIsUnique Specification object.

And finally we’re going to need to be able to add, update and find Groups from the database. To accomplish this we can define a GroupRepository interface in the Domain Layer and a GroupDoctrineORMRepositoy implementation in the Infrastructure layer.

As the scope and responsibility of the Groups Bounded Context expands we will need additional objects. However I’m all about not biting off more than you can chew, so what I’ve outlined above will be good for now.

Group ID Value Object

The first object we’ll create will be GroupId because it’s the easiest to write.

In the majority of web applications, Entities will have an id that is generated by the database. However in Cribbb, I’m using UUIDs that are generated in the application, not the database. If you want to read more about the reasoning behind this decision, take a look at The User Entity and The Ubiquitous Language.

The class for the GroupId is fairly simple:

<?php namespace Cribbb\Domain\Model\Groups;

use Rhumsaa\Uuid\Uuid;
use Cribbb\Domain\Identifier;
use Cribbb\Domain\UuidIdentifier;

class GroupId extends UuidIdentifier implements Identifier
{
    /**
     * @var Uuid
     */
    protected $value;

    /**
     * Create a new GroupId
     *
     * @return void
     */
    public function __construct(Uuid $value)
    {
        $this->value = $value;
    }
}

As with the UserId from this post, we can inherit much of the functionality from the abstract UuidIdentifier class.

The tests for this class are also fairly simple because we only really need to instantiate the class and make sure everything looks right:

<?php namespace Cribbb\Tests\Domain\Model\Groups;

use Rhumsaa\Uuid\Uuid;
use Cribbb\Domain\Model\Groups\GroupId;

class GroupIdTest extends \PHPUnit_Framework_TestCase
{
    /** @test */
    public function should_require_instance_of_uuid()
    {
        $this->setExpectedException("Exception");

        $id = new GroupId();
    }

    /** @test */
    public function should_create_new_group_id()
    {
        $id = new GroupId(Uuid::uuid4());

        $this->assertInstanceOf("Cribbb\Domain\Model\Groups\GroupId", $id);
    }

    /** @test */
    public function should_generate_new_group_id()
    {
        $id = GroupId::generate();

        $this->assertInstanceOf("Cribbb\Domain\Model\Groups\GroupId", $id);
    }

    /** @test */
    public function should_create_group_id_from_string()
    {
        $id = GroupId::fromString("d16f9fe7-e947-460e-99f6-2d64d65f46bc");

        $this->assertInstanceOf("Cribbb\Domain\Model\Groups\GroupId", $id);
    }

    /** @test */
    public function should_test_equality()
    {
        $one = GroupId::fromString("d16f9fe7-e947-460e-99f6-2d64d65f46bc");
        $two = GroupId::fromString("d16f9fe7-e947-460e-99f6-2d64d65f46bc");
        $three = GroupId::generate();

        $this->assertTrue($one->equals($two));
        $this->assertFalse($one->equals($three));
    }

    /** @test */
    public function should_return_group_id_as_string()
    {
        $id = GroupId::fromString("d16f9fe7-e947-460e-99f6-2d64d65f46bc");

        $this->assertEquals(
            "d16f9fe7-e947-460e-99f6-2d64d65f46bc",
            $id->toString()
        );
        $this->assertEquals(
            "d16f9fe7-e947-460e-99f6-2d64d65f46bc",
            (string) $id
        );
    }
}

This is the beauty of working with independent, simple objects!

Name and Slug Value Object

Next we can define the Name and Slug Value Objects. These objects should basically just validate that the chosen inputs meet the requirements of what are acceptable names for Groups.

These Value Objects will be very similar to the previous Value Objects that we covered in Encapsulating your application’s business rules.

Firstly, the Name Value Object should look like this:

<?php namespace Cribbb\Domain\Model\Groups;

use Assert\Assertion;
use Illuminate\Support\Str;
use Cribbb\Domain\ValueObject;

class Name implements ValueObject
{
    /**
     * @var string
     */
    private $value;

    /**
     * Create a new Name
     *
     * @param string $value
     * @return void
     */
    public function __construct($value)
    {
        Assertion::regex($value, '/^[\pL\pM\pN_-]+$/u');

        $this->value = $value;
    }

    /**
     * Create a Slug from the Name
     *
     * @return Slug
     */
    public function toSlug()
    {
        return new Slug(Str::slug($this->value));
    }

    /**
     * Create a new instance from a native form
     *
     * @param mixed $native
     * @return ValueObject
     */
    public static function fromNative($native)
    {
        return new Name($native);
    }

    /**
     * Determine equality with another Value Object
     *
     * @param ValueObject $object
     * @return bool
     */
    public function equals(ValueObject $object)
    {
        return $this == $object;
    }

    /**
     * Return the object as a string
     *
     * @return string
     */
    public function toString()
    {
        return $this->value;
    }

    /**
     * Return the object as a string
     *
     * @return string
     */
    public function __toString()
    {
        return $this->value;
    }
}

This object should implement the ValueObject interface so it can be easily identified and so we know we can determine equality with another Value Object.

I’ve defined a very simple regex for ensuring that only valid characters can appear in a Group’s name. I’ll probably revise this at some point in the future.

You will also notice that I’ve defined a toSlug() method that will return a new Slug object. The Slug should be generated from the Name because the two objects are intimately linked together. It doesn’t make sense to create a Slug outside of the context of a Name.

The Slug Value Object looks very similar to the Name Value Object:

<?php namespace Cribbb\Domain\Model\Groups;

use Assert\Assertion;
use Cribbb\Domain\ValueObject;

class Slug implements ValueObject
{
    /**
     * @var string
     */
    private $value;

    /**
     * Create a new Slug
     *
     * @param string $value
     * @return void
     */
    public function __construct($value)
    {
        Assertion::regex($value, '/^[\pL\pM\pN_-]+$/u');

        $this->value = $value;
    }

    /**
     * Create a new instance from a native form
     *
     * @param mixed $native
     * @return ValueObject
     */
    public static function fromNative($native)
    {
        return new Slug($native);
    }

    /**
     * Determine equality with another Value Object
     *
     * @param ValueObject $object
     * @return bool
     */
    public function equals(ValueObject $object)
    {
        return $this == $object;
    }

    /**
     * Return the object as a string
     *
     * @return string
     */
    public function toString()
    {
        return $this->value;
    }

    /**
     * Return the object as a string
     *
     * @return string
     */
    public function __toString()
    {
        return $this->value;
    }
}

As with the previous Value Object tests, the tests for the Name and Slug Value Objects are fairly simple. Once again, the beauty of simple independent objects makes writing tests a breeze:

<?php namespace Cribbb\Tests\Domain\Model\Groups;

use Cribbb\Domain\Model\Groups\Name;
use Cribbb\Domain\Model\Groups\Slug;

class NameTest extends \PHPUnit_Framework_TestCase
{
    /** @test */
    public function should_require_name()
    {
        $this->setExpectedException("Exception");
        $name = new Name();
    }

    /** @test */
    public function should_require_valid_name()
    {
        $this->setExpectedException("Assert\AssertionFailedException");

        $name = new Name("???");
    }

    /** @test */
    public function should_accept_valid_name()
    {
        $name = new Name("Cribbb");

        $this->assertInstanceOf("Cribbb\Domain\Model\Groups\Name", $name);
    }

    /** @test */
    public function should_create_slug()
    {
        $name = new Name("Cribbb");
        $slug = $name->toSlug();

        $this->assertInstanceOf("Cribbb\Domain\Model\Groups\Slug", $slug);
        $this->assertEquals(new Slug("cribbb"), $slug);
    }

    /** @test */
    public function should_create_from_native()
    {
        $name = Name::fromNative("Cribbb");

        $this->assertInstanceOf("Cribbb\Domain\Model\Groups\Name", $name);
    }

    /** @test */
    public function should_test_equality()
    {
        $one = new Name("Cribbb");
        $two = new Name("Cribbb");
        $three = new Name("Twitter");

        $this->assertTrue($one->equals($two));
        $this->assertFalse($one->equals($three));
    }

    /** @test */
    public function should_return_as_string()
    {
        $name = new Name("Cribbb");

        $this->assertEquals("Cribbb", $name->toString());
        $this->assertEquals("Cribbb", (string) $name);
    }
}
<?php namespace Cribbb\Tests\Domain\Model\Groups;

use Cribbb\Domain\Model\Groups\Slug;

class SlugTest extends \PHPUnit_Framework_TestCase
{
    /** @test */
    public function should_require_slug()
    {
        $this->setExpectedException("Exception");
        $slug = new Slug();
    }

    /** @test */
    public function should_require_valid_slug()
    {
        $this->setExpectedException("Assert\AssertionFailedException");
        $slug = new Slug("???");
    }

    /** @test */
    public function should_accept_valid_slug()
    {
        $slug = new Slug("cribbb");
        $this->assertInstanceOf("Cribbb\Domain\Model\Groups\Slug", $slug);
    }

    /** @test */
    public function should_create_from_native()
    {
        $slug = Slug::fromNative("cribbb");

        $this->assertInstanceOf("Cribbb\Domain\Model\Groups\Slug", $slug);
    }

    /** @test */
    public function should_test_equality()
    {
        $one = new Slug("cribbb");
        $two = new Slug("cribbb");
        $three = new Slug("twitter");

        $this->assertTrue($one->equals($two));
        $this->assertFalse($one->equals($three));
    }

    /** @test */
    public function should_return_as_string()
    {
        $slug = new Slug("cribbb");

        $this->assertEquals("cribbb", $slug->toString());
        $this->assertEquals("cribbb", (string) $slug);
    }
}

Group Entity

The next thing to do is to create the Group Entity. The Group Entity will be the main focal point of this Bounded Context. You can think of it as the mothership.

The Group Entity is fairly similar to the User Entity in it’s initial form:

<?php namespace Cribbb\Domain\Model\Groups;

use Cribbb\Domain\RecordsEvents;
use Doctrine\ORM\Mapping as ORM;
use Cribbb\Domain\AggregateRoot;
use Cribbb\Domain\Model\Identity\User;

/**
 * @ORM\Entity
 * @ORM\Table(name="groups")
 */
class Group implements AggregateRoot
{
    use RecordsEvents;

    /**
     * @ORM\Id
     * @ORM\Column(type="string")
     */
    private $id;

    /**
     * @ORM\Column(type="string")
     */
    private $name;

    /**
     * @ORM\Column(type="string")
     */
    private $slug;

    /**
     * Create a new Group
     *
     * @param GroupId $groupId
     * @param Name $name
     * @param Slug $slug
     * @return void
     */
    public function __construct(GroupId $groupId, Name $name, Slug $slug)
    {
        $this->setId($groupId);
        $this->setName($name);
        $this->setSlug($slug);
    }

    /**
     * Get the Group's id
     *
     * @return Group
     */
    public function id()
    {
        return GroupId::fromString($this->id);
    }

    /**
     * Set the Group's id
     *
     * @param GroupId $id
     * @return void
     */
    private function setId(GroupId $id)
    {
        $this->id = $id->toString();
    }

    /**
     * Get the Group's name
     *
     * @return string
     */
    public function name()
    {
        return Name::fromNative($this->name);
    }

    /**
     * Set the Group's name
     *
     * @param Name $name
     * @return void
     */
    private function setName(Name $name)
    {
        $this->name = $name->toString();
    }

    /**
     * Get the Group's slug
     *
     * @return string
     */
    public function slug()
    {
        return Slug::fromNative($this->slug);
    }

    /**
     * Set the Group's slug
     *
     * @param Slug $slug
     * @return void
     */
    private function setSlug(Slug $slug)
    {
        $this->slug = $slug->toString();
    }
}
/**
 * @ORM\Entity
 * @ORM\Table(name="groups")
 */

The first thing to notice is that this class has Doctrine annotations. Annotations describe your Entity so Doctrine knows how to work with. This means Doctrine can intelligently update the Database schema to ensure the database matches your Entity.

class Group implements AggregateRoot

Next this Entity should be identified as the Aggregate Root. The Aggregate Root is the most important Entity that should control access to it’s related Entities. We can identify this by implementing the AggregateRoot interface.

use RecordsEvents;

The Group Entity will eventually have important Domain Events that will be fired at certain points in it’s lifecycle. This means we can inform other aspect of the application of changes without having to couple the code together. The RecordsEvents trait give us access to a couple of methods that will make this easier to implement. To learn more about Domain Events, take a look at Implementing Domain Events.

Next we can define the basic properties of the Entity and the __construct() method:

/**
 * @ORM\Id
 * @ORM\Column(type="string")
 */
private $id;

/**
 * @ORM\Column(type="string")
 */
private $name;

/**
 * @ORM\Column(type="string")
 */
private $slug;

/**
 * Create a new Group
 *
 * @param GroupId $groupId
 * @param Name $name
 * @param Slug $slug
 * @return void
 */
public function __construct(GroupId $groupId, Name $name, Slug $slug)
{
    $this->setId($groupId);
    $this->setName($name);
    $this->setSlug($slug);
}

A Group requires a GroupId, Name and Slug in order to exist and so we can ensure that those properties are passed in when the object is created by requiring them in the __construct method.

We also use the object’s internal private setter methods to set those class properties.

Finally we can define the getters and setters of this object:

/**
 * Get the Group's id
 *
 * @return Group
 */
public function id()
{
    return GroupId::fromString($this->id);
}

/**
 * Set the Group's id
 *
 * @param GroupId $id
 * @return void
 */
private function setId(GroupId $id)
{
    $this->id = $id->toString();
}

/**
 * Get the Group's name
 *
 * @return string
 */
public function name()
{
    return Name::fromNative($this->name);
}

/**
 * Set the Group's name
 *
 * @param Name $name
 * @return void
 */
private function setName(Name $name)
{
    $this->name = $name->toString();
}

/**
 * Get the Group's slug
 *
 * @return string
 */
public function slug()
{
    return Slug::fromNative($this->slug);
}

/**
 * Set the Group's slug
 *
 * @param Slug $slug
 * @return void
 */
private function setSlug(Slug $slug)
{
    $this->slug = $slug->toString();
}

I prefer to have none-prefixed getters and private setters on my Entity objects. This provides a cleaner interface when working with the object and restricts how properties can be set on the object.

Once again the tests are fairly simple for this Entity because we can test it as just another object, rather than an object that is tied directly to the database:

<?php namespace Cribbb\Tests\Domain\Model\Groups;

use Rhumsaa\Uuid\Uuid;
use Cribbb\Domain\Model\Groups\Name;
use Cribbb\Domain\Model\Groups\Slug;
use Cribbb\Domain\Model\Groups\Group;
use Cribbb\Domain\Model\Groups\GroupId;

class GroupTest extends \PHPUnit_Framework_TestCase
{
    /** @var GroupId */
    private $id;

    /** @var Name */
    private $name;

    /** @var Slug */
    private $slug;

    public function setUp()
    {
        $this->id = new GroupId(Uuid::uuid4());
        $this->name = new Name("Cribbb");
        $this->slug = new Slug("cribbb");
    }

    /** @test */
    public function should_require_group_id()
    {
        $this->setExpectedException("Exception");

        $group = new Group(null, $this->name, $this->slug);
    }

    /** @test */
    public function should_require_name()
    {
        $this->setExpectedException("Exception");

        $group = new Group($this->id, null, $this->slug);
    }

    /** @test */
    public function should_require_slug()
    {
        $this->setExpectedException("Exception");

        $group = new Group($this->id, $this->name, null);
    }

    /** @test */
    public function should_create_new_group()
    {
        $group = new Group($this->id, $this->name, $this->slug);

        $this->assertInstanceOf("Cribbb\Domain\Model\Groups\Group", $group);
        $this->assertEquals($this->id, $group->id());
        $this->assertEquals($this->name, $group->name());
        $this->assertEquals($this->slug, $group->slug());
    }
}

These tests are basically just ensuring the object can be created and that the properties of the object are available as expected.

Group Repository

Next we can define the GroupRepository interface. The interface for a Repository should live in the Domain Layer of your application, whereas the actual implementation should live in the Infrastructure Layer. This means we can depend on the interface without coupling ourselves to any particular concrete implementation.

The GroupRepository is fairly similar to the UserRepository in its initial form:

<?php namespace Cribbb\Domain\Model\Groups;

interface GroupRepository
{
    /**
     * Return the next identity
     *
     * @return UserId
     */
    public function nextIdentity();

    /**
     * Add a new Group
     *
     * @param Group $group
     * @return void
     */
    public function add(Group $group);

    /**
     * Find a Group by it's Name
     *
     * @param Name $name
     * @return Group
     */
    public function groupOfName(Name $name);

    /**
     * Find a Group by it's Slug
     *
     * @param Slug $slug
     * @return Group
     */
    public function groupOfSlug(Slug $slug);
}

The nextIdentity() should generate and return a GroupId. This is because a Repository should act as a Collection of objects and so it is the Collection’s responsibility to determine the next id of it’s own objects.

If you would like to read a more in-depth article about the UserRepository take a look at Creating and testing Doctrine Repositories.

If you want to learn more about the idea of a Repository as a Collection of objects, take a look at What are the benefits of using Repositories?.

The add() method will allow us to add a new Group to the Collection. This method requires that we pass in a complete Group object.

And finally we have two methods to find a Group by it’s name or by it’s slug.

Notice how I’ve not defined methods to find a Group by it’s id, update, or delete a Group. I’m a strong believer in “don’t build it until you need it”, and so we can implement that functionality when we actually do need it.

Name is Unique Specification

An important part of our business rules is the fact that Group names should be unique. The Name Value Object can’t ensure that the chosen name is unique because it is unaware of the database.

Instead we need a way of selecting objects from the database and encapsulating the rule as an object.

This is where Specification objects come in. We’ve already created a couple of Specification object for email address and usernames. For more on this topic, take a look at Implementing The Specification Pattern.

The first thing to do is to define a NameSpecification interface. We will probably need multiple specifications and so it makes sense to define an interface so they are all consistent:

<?php namespace Cribbb\Domain\Model\Groups;

interface NameSpecification
{
    /**
     * Check to see if the specification is satisfied
     *
     * @param Name $name
     * @return bool
     */
    public function isSatisfiedBy(Name $name);
}

Next we can define the NameIsUnique specification that will check to see if the Group name is actually unique:

<?php namespace Cribbb\Domain\Model\Groups;

class NameIsUnique implements NameSpecification
{
    /**
     * @var GroupRepository
     */
    private $repository;

    /**
     * Create a new NameIsUnique specification
     *
     * @param GroupRepository $repository
     */
    public function __construct(GroupRepository $repository)
    {
        $this->repository = $repository;
    }

    /**
     * Check to see if the specification is satisfied
     *
     * @param Name $name
     * @return bool
     */
    public function isSatisfiedBy(Name $name)
    {
        if (!$this->repository->groupOfName($name)) {
            return true;
        }

        return false;
    }
}

Notice how we inject the GroupRepository interface into the object to query the database. This means we can very easily swap out the Repository implementation by simply using an object that implements that same interface. We can also write this Specification and test it without having to actually write the Repository implementation.

The tests for the NameIsUnique Specification look like this:

<?php namespace Cribbb\Tests\Domain\Model\Groups;

use Mockery as m;
use Cribbb\Domain\Model\Groups\Name;
use Cribbb\Domain\Model\Groups\NameIsUnique;

class NameIsUniqueTest extends \PHPUnit_Framework_TestCase
{
    /** @var GroupRepository */
    private $repository;

    /** @var NameIsUnique */
    private $spec;

    public function setUp()
    {
        $this->repository = m::mock(
            "Cribbb\Domain\Model\Groups\GroupRepository"
        );
        $this->spec = new NameIsUnique($this->repository);
    }

    /** @test */
    public function should_return_true_when_unique()
    {
        $this->repository->shouldReceive("groupOfName")->andReturn(null);
        $this->assertTrue($this->spec->isSatisfiedBy(new Name("Cribbb")));
    }

    /** @test */
    public function should_return_false_when_not_unique()
    {
        $this->repository->shouldReceive("groupOfName")->andReturn(["id" => 1]);
        $this->assertFalse($this->spec->isSatisfiedBy(new Name("Cribbb")));
    }
}

In order to test this Specification object we can mock the Repository so that we return the correct responses.

First we instruct the Repository to return null to simulate that no existing Group has the name we queried for. In this test the Specification should pass as true.

In the second test we instruct the Repository to return an array to simulate that an existing Group was found with that name. Under these circumstance the Specification object should return false.

Group Doctrine ORM Repository

Finally we can create the GroupDoctrineORMRepository class:

<?php namespace Cribbb\Infrastructure\Repositories;

use Doctrine\ORM\EntityManager;
use Cribbb\Domain\Model\Groups\Name;
use Cribbb\Domain\Model\Groups\Slug;
use Cribbb\Domain\Model\Groups\Group;
use Cribbb\Domain\Model\Groups\GroupId;
use Cribbb\Domain\Model\Groups\GroupRepository;

class GroupDoctrineORMRepository implements GroupRepository
{
    /**
     * @var EntityManager
     */
    private $em;

    /**
     * @var string
     */
    private $class;

    /**
     * Create a new GroupDoctrineORMRepository
     *
     * @param EntityManager $em
     * @return void
     */
    public function __construct(EntityManager $em)
    {
        $this->em = $em;
        $this->class = "Cribbb\Domain\Model\Groups\Group";
    }

    /**
     * Return the next identity
     *
     * @return GroupId
     */
    public function nextIdentity()
    {
        return GroupId::generate();
    }

    /**
     * Add a new Group
     *
     * @param Group $group
     * @return void
     */
    public function add(Group $group)
    {
        $this->em->persist($group);
        $this->em->flush();
    }

    /**
     * Find a Group by it's Name
     *
     * @param Name $name
     * @return Group
     */
    public function groupOfName(Name $name)
    {
        return $this->em->getRepository($this->class)->findOneBy([
            "name" => $name->toString(),
        ]);
    }

    /**
     * Find a Group by it's Slug
     *
     * @param Slug $slug
     * @return Group
     */
    public function groupOfSlug(Slug $slug)
    {
        return $this->em->getRepository($this->class)->findOneBy([
            "slug" => $slug->toString(),
        ]);
    }
}

This class is very similar to the UserDoctrineORMRepository we covered in Creating and testing Doctrine Repositories.

The tests for this Repository are also very similar to the UserDoctrineORMRepository tests and so I won’t repeat that code here because this tutorial is already long enough. If you are interested in writing and testing Doctrine Repositories, take a look at that previous tutorial.

Conclusion

A lot of this stuff is similar to what we’ve already covered in the previous weeks. Not every type of application will have this same structure, in-fact most will be radically different, but the simple patterns we’ve used can be implemented in project to project.

We’ve captured a lot of the logic of Cribbb in Domain Objects now. If you’ve been following along with this series, hopefully today’s article will have been a good recap to flesh out your understanding of the topic.

Next week we will continue to build out the Group Bounded Context.

This is a series of posts on building an entire Open Source application called Cribbb. All of the tutorials will be free to web, and all of the code is available on GitHub.

Philip Brown

@philipbrown

© Yellow Flag Ltd 2024.