- Part 1, Data-oriented Design
- Part 2, Domain-driven Design
This article is the last of this series. We have already described the problem encountered in data-oriented design. In this article, we will introduce a better way to tackle a feature requirement.
We continue the previous example, a sign-in mission, and try a different design flow. Before we start, let's review the onion architecture again.
In order to make it easier to understand the process to be introduced later, let's first define several important legends of this diagram.
- Entity: In clean architecture, entity means the business logic. Different from the entity in domain-driven design, the entity here can be realized as the domain in domain-driven design.
- Use cases: With the domain, the outer layer is use cases, which refers to clients who use domain knowledge to fulfill specific needs. In domain-driven design, it is also known as the domain service.
- Controller: The Controller is quite simple. It is responsible for managing the ingress and egress of the entire domain, including input checking, and converting domain knowledge into a data structure that is presented on the client side.
- DB: The outermost layer is the external dependencies of the system, including the database.
- Arrows: The arrow pointing from the outside to the inside is a reference. The outer module can reference the inner module, but it cannot be referenced from the inside to the outside.
According to this description, we can know that the order of design should be from the inside to the outside. After the inner layer is established, and then it is able to be referenced by the outer layer. In other words, to complete a design in a clean architecture way, the domain behavior must be defined first, and the database design should be the last. This is the exact opposite of data-oriented design.
Domain-driven Design
Before starting the actual design, let me explain my usual design process, which also echos the onion architecture.
- Discover user stories (entities)
- Design use cases
- Model domain objects
- Implement unit tests
- Code
In later sections, I will also design with this process. The problem we want to solve is to build a sign-in mission mentioned earlier.
Discover user stories
To start a design, we must be able to understand the whole picture of the entire requirement, and user stories are a language that can describe the requirements. In our needs this time, the stories are similar to the following.
- Get corresponding rewards when you sign in consecutively.
- Display the sign-in status and received rewards for this cycle.
- Get 100 diamonds when opening the gift box.
We convert the descriptions in the requirements document into semantics that developers can understand through an ubiquitous language. With any requirement, there must be a story behind it, and the designer's job is to discover those stories. On the other hand, for the developers, they implement those stories in coding.
Design use cases
With the story, then we need design the use cases that the story faces. Unlike a story, a use case refers to the outcome of a given user scenario. For example:
- Sign in: When a user signs in for four consecutive days, the first sign-in on the fifth day can get 30 diamonds and a gift box. But the second sign-in got nothing.
- Open the gift box: When opening the gift box, you can get 100 diamonds, but it cannot be opened again.
From the above description, use cases are actually an extension of user stories and describe details that are not defined in the story. Therefore, from the use cases, we can draw a flowchart to explain the whole user scenario in detail. Let's take sign-in as an example with a flowchart.
Starting from the top starting point, it is the moment when the sign-in action occurs, so it is represented by SignIn: now
. Next, we need to know how long is the difference between this sign-in and the "last sign-in" in days. If it is 0 days, it means that you have already signed in, and there is no reward to get. Or the difference is greater than 1, indicating that the sign-in is not continuous this time, and the entire cycle needs to be reset. In case of 1 exactly, it is continuous sign-in, thus the continuous date is incremented, and the current time is recorded.
Finally, check the table according to the number of consecutive days to know how many rewards you will get.
It is also easy to display how many consecutive days you have signed in. Suppose we use list to represent the signed-in records.
- Only sign in for one day:
[1, 0, 0, 0, 0, 0, 0]
- Sign in for three consecutive days:
[1, 1, 1, 0, 0, 0, 0]
Therefore, we can know how many 1
to insert to the list from counter
.
The flow of opening the gift box is similar, so I won't explain too much here. The final code will include opening the gift box.
Model domain objects
From the use cases we can know that we will need two very important variables: counter
and last
. In fact, the rest of the state is determined by these two variables, so we can start modeling.
In order to describe the entire sign-in mission, I believe that each user will have its own state, so we encapsulate the user state into a domain object called SignInRepo
. The Repository in DDD
is used here. Then with the user state, we can describe the whole story. There are two actions in the story, signIn
and getTimeline
, which represent story 1 and story 2 respectively.
Because SignInRepo
is defined on the basis of use cases, it is part of the entity in the onion architecture. According to the flow chart, it has two private variables and two public methods. The reason why update
has a parameter is that we can see from the flowchart that we only have one operation counter++, set last=now
, and now
must be passed in from the outside. As for SignInService
, it can be known from the name that he belongs to the domain service.
Once we have domain objects, we can start developing in test-driven development, TDD.
Implement unit tests
In the development process of TDD, we write the corresponding tests according to our user stories at first, and then the actual coding is carried out. Hence, in this section, we'll explain how to write unit tests with our defined stories and models. Let's take a regular story as an example, suppose we've signed in for six days continuously, and on the seventh day, we'll get 100 diamonds and a gift box.
First, write a test based on our story.
describe("step1", () => {
it("continuous 6d and signin 7th day", () => {
const user = "User A";
const now = "2022-01-07 1:11:11";
const service = new SignInService(user);
const timeline1 = service.getTimeline();
expect(timeline1).to.deep.equal([1, 1, 1, 1, 1, 1, 0]);
const result = service.signIn(now);
expect(result).to.be.equal(100);
const timeline2 = service.getTimeline();
expect(timeline2).to.deep.equal([1, 1, 1, 1, 1, 1, 1]);
const result = service.signIn(now);
expect(result).to.be.equal(0);
});
});
One of the stories is briefly described above, there is a user, A, who has signed in for six consecutive days, and when he signs in at 2022-01-07 1:11:11
, it is the seventh day to sign in. He gets 100 diamonds as our expectation.
But such a story is not complete, because six consecutive sign-ins have not been defined. So let's modify the test a bit.
describe("step2", () => {
it("continuous 6d and signin 7th day", () => {
const user = "User A";
const now = "2022-01-07 1:11:11";
const repo = new SignInRepo(user);
repo.restoreSingInRecord(6, "2022-01-06 5:55:55");
const service = new SignInService(repo);
const timeline1 = service.getTimeline();
expect(timeline1).to.deep.equal([1, 1, 1, 1, 1, 1, 0]);
const result = service.signIn(now);
expect(result).to.be.equal(100);
const timeline2 = service.getTimeline();
expect(timeline2).to.deep.equal([1, 1, 1, 1, 1, 1, 1]);
const result = service.signIn(now);
expect(result).to.be.equal(0);
});
});
In order to restore the entire use cases, we newly defined a repo and added an auxiliary method: restoreSingInRecord
. This helper can also be used as an interface to retrieve values from the database in future implementations. Subsequently, such a story is complete and can begin to go into the production code.
Code
In the previous section, we have a complete unit test, and then start implementing SignInRepo
and SignInService
.
class SignInRepo {
constructor(user) {
this.user = user;
this.counter = 0;
this.last = null;
}
restoreSingInRecord(counter, last) {
this.counter = counter;
this.last = last;
}
update(now) {
this.counter++;
this.last = now;
}
reset() {
this.counter = 0;
this.last = null;
}
}
class SignInService {
constructor(repo) {
this.repo = repo;
}
signIn(now) {
const diffDay = dateDiff(now, this.repo.last);
if (diffDay === 0) {
return 0;
}
if (diffDay > 1) {
this.repo.reset();
}
this.repo.update(now);
return table[this.repo.counter - 1] || 0;
}
getTimeline() {
const ret = [0, 0, 0, 0, 0, 0, 0];
if (!this.repo.counter) return ret;
for (let i = 0; i < 7; i++) {
if (i < this.repo.counter) ret[i] = 1;
}
return ret;
}
}
SignInRepo
is easy to implement when there is no database, just follow the flowchart to finish update
and reset
. SignInService
is totally implemented in accordance with the use cases, and the flowchart is converted into the actual code.
In this way, this requirement is half completed, and the remaining process of opening the gift box is basically the same, so I will just post the final result. The full implementation can be seen as follows.
const expect = require("chai").expect;
const table = [10, 10, 15, 15, 30, 30, 100];
const boxTable = [0, 1, 0, 0, 1, 0, 1];
const dateDiff = (sD1, sD2) => {
const d1 = new Date(sD1);
const d2 = new Date(sD2);
d1.setHours(0, 0, 0, 0);
d2.setHours(0, 0, 0, 0);
return Math.abs(d1 - d2) / 86400000;
};
class SignInRepo {
constructor(user) {
this.user = user;
this.counter = 0;
this.last = null;
this.lastBox = -1;
}
restoreSingInRecord(counter, last, lastBox) {
this.counter = counter;
this.last = last;
this.lastBox = lastBox;
}
update(now) {
this.counter++;
this.last = now;
}
reset() {
this.counter = 0;
this.last = null;
this.lastBox = -1;
}
setLastBox(lastBox) {
this.lastBox = lastBox;
}
}
class SignInService {
constructor(repo) {
this.repo = repo;
}
signIn(now) {
const diffDay = dateDiff(now, this.repo.last);
if (diffDay === 0) {
return 0;
}
if (diffDay > 1) {
this.repo.reset();
}
this.repo.update(now);
return table[this.repo.counter - 1] || 0;
}
getTimeline() {
const ret = [0, 0, 0, 0, 0, 0, 0];
if (!this.repo.counter) return ret;
for (let i = 0; i < 7; i++) {
if (i < this.repo.counter) ret[i] = 1;
}
return ret;
}
click() {
for (let i = this.repo.lastBox + 1; i < this.repo.counter; i++) {
if (boxTable[i] === 1) {
this.repo.setLastBox(i);
return 100;
}
}
return 0;
}
}
describe("step4", () => {
it("continuous 6d and signin 7th day", () => {
const user = "User A";
const now = "2022-01-07 1:11:11";
const repo = new SignInRepo();
repo.restoreSingInRecord(6, "2022-01-06 5:55:55", 1);
const service = new SignInService(repo);
const timeline1 = service.getTimeline();
expect(timeline1).to.deep.equal([1, 1, 1, 1, 1, 1, 0]);
const result = service.signIn(now);
expect(result).to.be.equal(100);
const timeline2 = service.getTimeline();
expect(timeline2).to.deep.equal([1, 1, 1, 1, 1, 1, 1]);
});
it("continuous 6d and click box", () => {
const user = "User A";
const now = "2022-01-06 11:11:11";
const repo = new SignInRepo();
repo.restoreSingInRecord(6, "2022-01-06 5:55:55", 1);
const service = new SignInService(repo);
const boxReward1 = service.click(now);
expect(boxReward1).to.be.equal(100);
expect(repo.lastBox).to.be.equal(4);
const boxReward2 = service.click(now);
expect(boxReward2).to.be.equal(0);
expect(repo.lastBox).to.be.equal(4);
});
});
Summary of Domain-driven Design
In fact, the above implementation just borrows some DDD terminologies, and does not fully implement as DDD's "prescriptions". From my point of view, DDD provides a concept that enables people to know the importance of the domain, and has the ability to abstract the domain. That is to say it is up to you whether to follow the the textbook to implement Entity, Value Object, Aggregate and Repository or not. It needn't implement in DDD by following the textbook approach. The implementation depends on the proficiency and understanding of needs.
In this article, a standard design process is provided, so that everyone can disassemble the original requirements and convert them into models with domain knowledge by following this process. In the process of implementing the model, it begins with the corresponding tests to achieve test-driven development.
Of course, in the real world, it is not as simple as the example in this article. But the design process is the same, starting from the story, defining the use cases through the story, then modeling according to the use cases, writing tests according to the stories, and finally implementing it.
By the way, I explained some design details a while ago, such as:
- Q1: Why do we need to define a repo?
- Ans: Dependency Injection
- Q2: Why do we need layers?
- Ans: Layered Aechitecture
- Q3: How to evolve a system?
If you encounter software design problems, you are also welcome to discuss with me.
Top comments (0)