Implement DCI architecture

Keywords: Go

preface

In the concept of object-oriented programming, an application is an abstraction of the real world. We often model real things as classes / objects ("what") in the programming language, and the behavior of things as methods ("what to do"). Object oriented programming has three basic characteristics (encapsulation, inheritance / combination and polymorphism) and five basic principles (single responsibility principle, open and closed principle, Richter substitution principle, dependency inversion principle and interface separation principle), but knowing these are not enough for us to design good programs, so many methodologies have emerged.

Recently, the most popular is Domain Driven Design (DDD). The modeling methods such as entity, value object and aggregation proposed by tactical modeling can well guide us to design domain models in line with the real world. However, DDD is not omnipotent. In some application scenarios, programs designed according to the traditional tactical modeling / object-oriented method will also have problems such as poor maintainability and violation of the principle of single responsibility.

The DCI modeling method introduced in this paper can be regarded as an auxiliary of tactical modeling. In some scenarios, it can make up for some shortcomings of DDD tactical modeling. Next, we will introduce how DCI solves these shortcomings of DDD tactical modeling through a case.

The code involved in this article is archived in the GitHub project: https://github.com/ruanrunxue/DCI-Architecture-Implementation

case

Considering the daily life of an ordinary person, he will have classes at school, work in the company during the summer vacation, play in the park after work, and eat, drink and have fun at home like ordinary people. Of course, a person's life is far more than that. For the convenience of explanation, this paper only models these typical scenarios.

Modeling with DDD

According to the idea of DDD tactical modeling, first, we will list the common language of the case:

Person, ID card, bank card, home, eat, sleep, play games, school, student card, study, examination, company, work card, work, work, park, ticket purchase and play

Then, we use tactical modeling techniques (value object, entity, aggregation, domain service, resource library) to model the domain of the common language.

The code directory structure after DDD modeling is as follows:

- aggregate: polymerization
  - company.go
  - home.go
  - park.go
  - school.go
- entity: entity
  - people.go
- vo: Value object
  - account.go
  - identity_card.go
  - student_card.go
  - work_card.go

We model the concepts of ID card, student card, work card and bank card as Value Object:

package vo

// ID
type IdentityCard struct {
	Id   uint32
	Name string
}

// Student card
type StudentCard struct {
	Id     uint32
	Name   string
	School string
}

// Job card
type WorkCard struct {
	Id      uint32
	Name    string
	Company string
}

// bank card
type Account struct {
	Id      uint32
	Balance int
}

...

Next, we model a person as an Entity, which includes equivalent objects such as ID card and student card, and also has behaviors such as eating and sleeping:

package entity

// people
type People struct {
	vo.IdentityCard
	vo.StudentCard
	vo.WorkCard
	vo.Account
}

// study
func (p *People) Study() {
	fmt.Printf("Student %+v studying\n", p.StudentCard)
}
// examination
func (p *People) Exam() {
	fmt.Printf("Student %+v examing\n", p.StudentCard)
}
// having dinner
func (p *People) Eat() {
	fmt.Printf("%+v eating\n", p.IdentityCard)
	p.Account.Balance--
}
// sleep
func (p *People) Sleep() {
	fmt.Printf("%+v sleeping\n", p.IdentityCard)
}
// play a game
func (p *People) PlayGame() {
	fmt.Printf("%+v playing game\n", p.IdentityCard)
}
// go to work
func (p *People) Work() {
	fmt.Printf("%+v working\n", p.WorkCard)
	p.Account.Balance++
}
// go off work
func (p *People) OffWork() {
	fmt.Printf("%+v getting off work\n", p.WorkCard)
}
// Ticket purchase
func (p *People) BuyTicket() {
	fmt.Printf("%+v buying a ticket\n", p.IdentityCard)
	p.Account.Balance--
}
// play
func (p *People) Enjoy() {
	fmt.Printf("%+v enjoying park scenery\n", p.IdentityCard)
}

Finally, we model schools, companies, parks and homes as aggregations, which are composed of one or more entities and value objects to organize them to complete specific business logic:

package aggregate

// home
type Home struct {
	me *entity.People
}
func (h *Home) ComeBack(p *entity.People) {
	fmt.Printf("%+v come back home\n", p.IdentityCard)
	h.me = p
}
// Execute the business logic of Home
func (h *Home) Run() {
	h.me.Eat()
	h.me.PlayGame()
	h.me.Sleep()
}

// school
type School struct {
	Name     string
	students []*entity.People
}
func (s *School) Receive(student *entity.People) {
	student.StudentCard = vo.StudentCard{
		Id:     rand.Uint32(),
		Name:   student.IdentityCard.Name,
		School: s.Name,
	}
	s.students = append(s.students, student)
	fmt.Printf("%s Receive stduent %+v\n", s.Name, student.StudentCard)
}
// Execute the business logic of School
func (s *School) Run() {
	fmt.Printf("%s start class\n", s.Name)
	for _, student := range s.students {
		student.Study()
	}
	fmt.Println("students start to eating")
	for _, student := range s.students {
		student.Eat()
	}
	fmt.Println("students start to exam")
	for _, student := range s.students {
		student.Exam()
	}
	fmt.Printf("%s finish class\n", s.Name)
}

// company
type Company struct {
	Name    string
	workers []*entity.People
}
func (c *Company) Employ(worker *entity.People) {
	worker.WorkCard = vo.WorkCard{
		Id:      rand.Uint32(),
		Name:    worker.IdentityCard.Name,
		Company: c.Name,
	}
	c.workers = append(c.workers, worker)
	fmt.Printf("%s Employ worker %s\n", c.Name, worker.WorkCard.Name)
}
// Execute Company's business logic
func (c *Company) Run() {
	fmt.Printf("%s start work\n", c.Name)
	for _, worker := range c.workers {
		worker.Work()
	}
	fmt.Println("worker start to eating")
	for _, worker := range c.workers {
		worker.Eat()
	}
	fmt.Println("worker get off work")
	for _, worker := range c.workers {
		worker.OffWork()
	}
	fmt.Printf("%s finish work\n", c.Name)
}

// park
type Park struct {
	Name     string
	enjoyers []*entity.People
}
func (p *Park) Welcome(enjoyer *entity.People) {
	fmt.Printf("%+v come to park %s\n", enjoyer.IdentityCard, p.Name)
	p.enjoyers = append(p.enjoyers, enjoyer)
}
// Execute Park's business logic
func (p *Park) Run() {
	fmt.Printf("%s start to sell tickets\n", p.Name)
	for _, enjoyer := range p.enjoyers {
		enjoyer.BuyTicket()
	}
	fmt.Printf("%s start a show\n", p.Name)
	for _, enjoyer := range p.enjoyers {
		enjoyer.Enjoy()
	}
	fmt.Printf("show finish\n")
}

Then, the model modeled according to the above method is as follows:

The operation method of the model is as follows:

paul := entity.NewPeople("Paul")
mit := aggregate.NewSchool("MIT")
google := aggregate.NewCompany("Google")
home := aggregate.NewHome()
summerPalace := aggregate.NewPark("Summer Palace")
// go to school
mit.Receive(paul)
mit.Run()
// get home
home.ComeBack(paul)
home.Run()
// work
google.Employ(paul)
google.Run()
// Park play
summerPalace.Welcome(paul)
summerPalace.Run()

Anemia Model VS congestion model (Engineering VS academic)

In the previous section, we completed the case domain model using the tactical modeling of DDD. The core of the model is the People entity. It has data attributes such as IdentityCard and StudentCard, as well as business behaviors such as Eat(), Study(), Work(), which is very consistent with the definition in the real world. This is also a congestion model advocated by the academic school, which has both data attributes and business behavior.

However, the congestion model is not perfect, and it also has many problems. These two are typical:

Question 1: God

The People entity contains too many responsibilities, resulting in it becoming a veritable God class. Just imagine that many attributes and behaviors contained in "People" are trimmed here. If you want to model a complete model, there are too many attributes and methods to imagine. God class violates the principle of single responsibility, which will lead to poor maintainability of code.

Problem 2: coupling between modules

School and Company should be independent of each other. School doesn't have to pay attention to whether to go to work or not, and Company doesn't have to pay attention to whether to take exams or not. But now, because they all rely on the People entity, school can call the Work() and OffWork() methods related to Company, and vice versa. This leads to unnecessary coupling between modules, which violates the principle of interface isolation.

These problems are unacceptable to the engineering school. From the perspective of software engineering, they will make the code difficult to maintain. A common way to solve this problem is to split entities, such as modeling the behavior of entities into domain services, such as:

type People struct {
	vo.IdentityCard
	vo.StudentCard
	vo.WorkCard
	vo.Account
}

type StudentService struct{}
func (s *StudentService) Study(p *entity.People) {
	fmt.Printf("Student %+v studying\n", p.StudentCard)
}
func (s *StudentService) Exam(p *entity.People) {
	fmt.Printf("Student %+v examing\n", p.StudentCard)
}

type WorkerService struct{}
func (w *WorkerService) Work(p *entity.People) {
	fmt.Printf("%+v working\n", p.WorkCard)
	p.Account.Balance++
}
func (w *WorkerService) OffWOrk(p *entity.People) {
	fmt.Printf("%+v getting off work\n", p.WorkCard)
}

// ...

This modeling method solves the above two problems, but it has also become the so-called anemia model: People has become a pure data class without any business behavior. In human psychology, such a model can not establish a corresponding relationship with the real world, which is not easy to understand, so it is resisted by the Academy.

So far, both anemia model and congestion model have their own advantages and disadvantages. Neither engineering school nor academic school can convince each other. Next, it's the protagonist's turn.

DCI architecture

DCI (Data, Context, Interactive) architecture is an object-oriented software architecture pattern< The DCI Architecture: A New Vision of Object-Oriented Programming >It is proposed for the first time. Compared with traditional object-oriented, DCI can better model the relationship between data and behavior, so it is easier to be understood.

  • Data, that is, data / domain objects, is used to describe the "what" of the system. Tactical modeling in DDD is usually used to identify the domain objects of the current model, which is equivalent to the domain layer in DDD layered architecture.
  • Context, that is, scenario, can be understood as the Use Case of the system, which represents the business processing process of the system and is equivalent to the application layer in the DDD layered architecture.
  • Interactive, that is, interaction, is the biggest development of DCI compared with traditional Object-oriented. It believes that we should explicitly model the role of domain objects in each business Context. Role represents the business behavior ("what to do") of domain objects in the business scenario. Roles complete the complete obligation process through interaction.

This role-playing model is not new to us and can be seen everywhere in the real world. For example, an actor can play the role of a hero in this film or a villain in another film.

DCI believes that the modeling of Role should be Context oriented, because a specific business behavior will be meaningful only in a specific business scenario. Through the modeling of Role, we can split the methods of domain objects, so as to avoid the emergence of God class. Finally, domain objects integrate roles through composition or inheritance, so they have the ability to play roles.

On the one hand, DCI architecture makes the domain model easy to understand through the Role-playing model. On the other hand, it avoids the problem of God class by means of "small class and large object", so as to better solve the dispute between anemia model and congestion model. In addition, after splitting the behavior of domain objects according to roles, the modules are more cohesive and less coupled.

Modeling with DCI

Back to the previous case, using the modeling idea of DCI, we can divide several behaviors of "people" according to different roles. Eating, sleeping and playing games are the behaviors of human characters; Study and examination are the behaviors of students' roles; Going to and from work is the behavior of employees; Buying tickets and playing is the behavior of the player. In the scene of "people" at home, they act as human beings; In the school scene, it plays the role of students; In the company scenario, it acts as an employee; In the park scene, it acts as a player.

It should be noted that students, employees and players should have the behavior of human roles. For example, in school, students also need to eat.

Finally, according to the model modeled by DCI, it should be as follows:

In the DCI model, People is no longer a "God class" containing many attributes and methods. These attributes and methods are split into multiple roles, and People is composed of these roles.

In addition, school and Company are no longer coupled. School only references students and cannot call the Work() and OffWorker() methods of workers related to Company.

Code implementation DCI model

The code directory structure after DCI modeling is as follows;

- context: scene
  - company.go
  - home.go
  - park.go
  - school.go
- object: object
  - people.go
- data: data
  - account.go
  - identity_card.go
  - student_card.go
  - work_card.go
- role: role
  - enjoyer.go
  - human.go
  - student.go
  - worker.go

From the perspective of code directory structure, there is little difference between DDD and DCI architecture, and the aggregate directory has evolved into a context directory; vo directory evolved into data directory; The entity directory has evolved into object and role directories.

First, we need to combine the basic role Human, Student, Worker and Enjoyer:

package role

// Human role
type Human struct {
	data.IdentityCard
	data.Account
}
func (h *Human) Eat() {
	fmt.Printf("%+v eating\n", h.IdentityCard)
	h.Account.Balance--
}
func (h *Human) Sleep() {
	fmt.Printf("%+v sleeping\n", h.IdentityCard)
}
func (h *Human) PlayGame() {
	fmt.Printf("%+v playing game\n", h.IdentityCard)
}

Then, we will implement other roles. It should be noted that students, workers and enjoyers cannot directly combine Human, otherwise the People object will have four Human sub objects, which is inconsistent with the model:

// Wrong implementation
type Worker struct {
	Human
}
func (w *Worker) Work() {
	fmt.Printf("%+v working\n", w.WorkCard)
	w.Balance++
}
...
type People struct {
	Human
	Student
	Worker
	Enjoyer
}
func main() {
	people := People{}
  fmt.Printf("People: %+v", people)
}
// Result output: there are 4 People in People:
// People: {Human:{} Student:{Human:{}} Worker:{Human:{}} Enjoyer:{Human:{}}}

To solve this problem, we introduced the xxtrain interface:

// Human role characteristics
type HumanTrait interface {
	CastHuman() *Human
}
// Student role characteristics
type StudentTrait interface {
	CastStudent() *Student
}
// Employee role characteristics
type WorkerTrait interface {
	CastWorker() *Worker
}
// Player role characteristics
type EnjoyerTrait interface {
	CastEnjoyer() *Enjoyer
}

Student, Worker and Enjoyer combine HumanTrait and inject features through the Compose(HumanTrait) method. This problem can be solved as long as the Human is the same at the time of injection.

// Student role
type Student struct {
	// Student is also an ordinary person, so he combines the Human role
	HumanTrait
	data.StudentCard
}
// Inject human character characteristics
func (s *Student) Compose(trait HumanTrait) {
	s.HumanTrait = trait
}
func (s *Student) Study() {
	fmt.Printf("Student %+v studying\n", s.StudentCard)
}
func (s *Student) Exam() {
	fmt.Printf("Student %+v examing\n", s.StudentCard)
}

// Employee role
type Worker struct {
	// Worker is also an ordinary person, so he combines the Human role
	HumanTrait
	data.WorkCard
}
// Inject human character characteristics
func (w *Worker) Compose(trait HumanTrait) {
	w.HumanTrait = trait
}
func (w *Worker) Work() {
	fmt.Printf("%+v working\n", w.WorkCard)
	w.CastHuman().Balance++
}
func (w *Worker) OffWork() {
	fmt.Printf("%+v getting off work\n", w.WorkCard)
}

// Player role
type Enjoyer struct {
	// Enjoyer is also an ordinary person, so he combines the Human role
	HumanTrait
}
// Inject human character characteristics
func (e *Enjoyer) Compose(trait HumanTrait) {
	e.HumanTrait = trait
}
func (e *Enjoyer) BuyTicket() {
	fmt.Printf("%+v buying a ticket\n", e.CastHuman().IdentityCard)
	e.CastHuman().Balance--
}
func (e *Enjoyer) Enjoy() {
	fmt.Printf("%+v enjoying scenery\n", e.CastHuman().IdentityCard)
}

Finally, implement the domain object of People:

package object

type People struct {
	// The role of People objects
	role.Human
	role.Student
	role.Worker
	role.Enjoyer
}
// People implements characteristic interfaces such as HumanTrait, StudentTrait, workertait, enjoyertait, etc
func (p *People) CastHuman() *role.Human {
	return &p.Human
}
func (p *People) CastStudent() *role.Student {
	return &p.Student
}
func (p *People) CastWorker() *role.Worker {
	return &p.Worker
}
func (p *People) CastEnjoyer() *role.Enjoyer {
	return &p.Enjoyer
}
// People completes the injection of role features during initialization
func NewPeople(name string) *People {
  // Some initialization logic
	people.Student.Compose(people)
	people.Worker.Compose(people)
	people.Enjoyer.Compose(people)
	return people
}

After role splitting, when implementing Home, School, Company, Park and other scenarios, you only need to rely on the corresponding roles, and you no longer need to rely on objects in the field of People:

// home
type Home struct {
	me *role.Human
}
func (h *Home) ComeBack(human *role.Human) {
	fmt.Printf("%+v come back home\n", human.IdentityCard)
	h.me = human
}
// Execute the business logic of Home
func (h *Home) Run() {
	h.me.Eat()
	h.me.PlayGame()
	h.me.Sleep()
}

// school
type School struct {
	Name     string
	students []*role.Student
}
func (s *School) Receive(student *role.Student) {
  // Initialize StduentCard logic
	s.students = append(s.students, student)
	fmt.Printf("%s Receive stduent %+v\n", s.Name, student.StudentCard)
}
// Execute the business logic of School
func (s *School) Run() {
	fmt.Printf("%s start class\n", s.Name)
	for _, student := range s.students {
		student.Study()
	}
	fmt.Println("students start to eating")
	for _, student := range s.students {
		student.CastHuman().Eat()
	}
	fmt.Println("students start to exam")
	for _, student := range s.students {
		student.Exam()
	}
	fmt.Printf("%s finish class\n", s.Name)
}

// company
type Company struct {
	Name    string
	workers []*role.Worker
}
func (c *Company) Employ(worker *role.Worker) {
  // Initialize WorkCard logic
  c.workers = append(c.workers, worker)
	fmt.Printf("%s Employ worker %s\n", c.Name, worker.WorkCard.Name)
}
// Execute Company's business logic
func (c *Company) Run() {
	fmt.Printf("%s start work\n", c.Name)
	for _, worker := range c.workers {
		worker.Work()
	}
	fmt.Println("worker start to eating")
	for _, worker := range c.workers {
		worker.CastHuman().Eat()
	}
	fmt.Println("worker get off work")
	for _, worker := range c.workers {
		worker.OffWork()
	}
	fmt.Printf("%s finish work\n", c.Name)
}

// park
type Park struct {
	Name     string
	enjoyers []*role.Enjoyer
}
func (p *Park) Welcome(enjoyer *role.Enjoyer) {
	fmt.Printf("%+v come park %s\n", enjoyer.CastHuman().IdentityCard, p.Name)
	p.enjoyers = append(p.enjoyers, enjoyer)
}
// Execute Park's business logic
func (p *Park) Run() {
	fmt.Printf("%s start to sell tickets\n", p.Name)
	for _, enjoyer := range p.enjoyers {
		enjoyer.BuyTicket()
	}
	fmt.Printf("%s start a show\n", p.Name)
	for _, enjoyer := range p.enjoyers {
		enjoyer.Enjoy()
	}
	fmt.Printf("show finish\n")
}

The operation method of the model is as follows:

paul := object.NewPeople("Paul")
mit := context.NewSchool("MIT")
google := context.NewCompany("Google")
home := context.NewHome()
summerPalace := context.NewPark("Summer Palace")

// go to school
mit.Receive(paul.CastStudent())
mit.Run()
// get home
home.ComeBack(paul.CastHuman())
home.Run()
// work
google.Employ(paul.CastWorker())
google.Run()
// Park play
summerPalace.Welcome(paul.CastEnjoyer())
summerPalace.Run()

Write at the end

In the scenario described above, we can find that the traditional DDD / object-oriented design method has shortcomings in modeling behavior, which leads to the dispute between the so-called anemia model and congestion model.

The emergence of DCI architecture makes up for this. It skillfully solves the coupling problem between God classes and modules in the congestion model by introducing the idea of role-playing, and does not affect the correctness of the model. Of course, DCI architecture is not omnipotent. In business models with less behavior, it is not appropriate to use DCI to model.

Finally, the DCI architecture is summarized into one sentence: domain objects play different roles in different contexts, and the specific business logic is completed through interaction between roles.

reference resources

1,The DCI Architecture: A New Vision of Object-Oriented Programming, Trygve Reenskaug & James O. Coplien

2,Evolution of software design , _ Zhang Xiaolong_

3,Implement Domain Object in Golang , _ Zhang Xiaolong_

4,DCI: Code intelligibility, chelsea

5,DCI in C++, MagicBowen

More articles please pay attention to WeChat official account: invitation of Yuan run Zi

Posted by nanobots on Sat, 09 Oct 2021 22:12:37 -0700