Why Isn't There a Service!? - The Story of Introducing the Service Layer
Just give me the service

Just give me the service

Flask is a highly flexible web framework. While developing backends with Flask, there was one aspect I hadn’t fully considered—the “Service Layer”—but after implementing it, I found it to be very useful, so I’d like to share my thoughts on it here.
Ever since I joined the company, I’ve been developing exclusively with Flask. I was assigned to an ongoing project and tried to follow the company’s designated development architecture. While it generally followed the MVC pattern, it wasn’t a strict MVC implementation. To explain the folder structure in detail, it was broadly divided into controllers, models, and 백그라운드 태스크에서 수행할 로직이 정리된 각 도메인의 폴더, and we developed using this structure. An example of the folder structure is as follows. (I will not explain other folders that are off-topic!)
main ㄴ controllers ㄴ domain_a ㄴ list.py ㄴ info.py ㄴ domain_b ㄴ list.py ㄴ domain_a ㄴ extractors.py ㄴ models ㄴ domain_a.py ㄴ domain_b.py
models
python# 예시라서 자잘한 import와 코드는 생략... class DomainA(db.model): id = db.Column(db.Integer, primary=True, autoincrement=True) name = db.Column(db.String) @classmethod def get(cls, id_): return cls.query.filter(cls.id == id_).one_or_none() @classmethod def insert(cls, name): domain_a_inst = cls(name) db.session.insert(domain_a_inst) db.session.commit() return domain_a_inst.id def update(self, name): self.name = name db.session.commit()
controllers
python# 예시라서 자잘한 import와 코드는 생략... from main.models.domain_a import DomainA domain_a_bp = Blueprint("domain_a", __name__, "/api/domain-a") @domain_a_bp.patch("") def get_domain_a(id, name): """업데이트 API""" domain_a = DomainA.get(id) if domain_a is None: return "Not Found", 404 domain_a.update(name) return domain_a._asdict(), 200
백그라운드 태스크에서 수행할 로직이 정리된 각 도메인의 폴더(?)
main/domain_1.Then, as new senior developers joined the team, they pointed out the problems with the current situation. During a backend meeting, they asked, “Why isn’t there a Service Layer?” and tried to convince us to introduce one. Now that I think about it, they’re right. “Why wasn’t there one?”
It may not have been a major consideration at first. “Flask is flexible by nature,” “The service wasn’t that big,” “As long as the application runs, that’s all that matters!” and so on… However, the service has grown significantly, and the number of domains that need to be incorporated into the project has increased dramatically. (While there were only 2–3 domains in the early stages, there are now nearly 10.) As management was becoming increasingly difficult, I thought that separating at least the business logic into a separate layer might improve manageability, so I actively supported the introduction of a service layer.

The Service Layer, also known as the “Business Layer,” is the layer where basic business logic is implemented. It refers to the process of taking data received from the front end and generating results based on functional definitions.
To put it more simply, it’s the process of implementing in code what happens in a restaurant kitchen when dishes are prepared according to specific procedures and sequences after an order is received.

(Anyway... let’s move on~)
Let’s revisit the controller example above.
pythonfrom main.models.domain_a import DomainA domain_a_bp = Blueprint("domain_a", __name__, "/api/domain-a") @domain_a_bp.patch("") def update_domain_a(id, name): domain_a = DomainA.get(id) if domain_a is None: return "Not Found", 404 domain_a.update(id, name=name) return domain_a._asdict(), 200
The business logic here would be: “Retrieve data using the ID from the DomainA table. If that data does not exist, display ‘Not Found.’” Let’s break this down neatly into the service layer!
Define it as follows in the script main/services/domain_a.py.
python from main.models.domain_a import DomainA class DomainASerivce: @staticmethod def update(id_, name): # 트랜잭션 관리하는 건 생략, 트랜잭션에 대한 내용은 다른 포스팅에서 다루겠다. domain_a = DomainA.get(id_) domain_a.update(name) return domain_a
And in the controller, instead of retrieving and using the model, we retrieve and use the service.
pythonfrom main.service.domain_a import DomainAService domain_a_bp = Blueprint("domain_a", __name__, "/api/domain-a") @domain_a_bp.patch("") def update_domain_a(id, name): try: domain_a = DomainAService.update(id, name) return domain_a._asdict(), 200 except NotFoundError: return "Not Found". 404
We can definitely say that the service and controller are now separated. (Since this is a simple implementation, the difference might not be very noticeable...)

The following issues were resolved by separating the service:
Previously, since business logic was implemented within the controller, I sometimes created separate functions to accommodate cases where the same logic would be used in other controllers or background task handlers; however, this led to situations where the controller script had to be referenced. Here’s an example:
Even if the update logic above is encapsulated into a function as shown below,
pythonfrom main.models.domain_a import DomainA domain_a_bp = Blueprint("domain_a", __name__, "/api/domain-a") def update_domain_a(id, name): # 컨트롤러 내부에 함수를 만들어 쓰곤 했었다. domain_a = DomainA.get(id) domain_a.update(id, name=name) return domain_a @domain_a_bp.patch("") def update_domain_a(id, name): try: domain_a = update_domain_a(id, name) return domain_a._asdict(), 200 except NotFoundError: return "Not Found", 404
if you want to use the update_domain_a function in another script, you must import the controller script above. Furthermore, when functions were used in this way across different controllers, circular imports (where files reference each other) frequently occurred.
By separating business logic into a service layer, you avoid situations like the one above where
controller scripts are referenced or circular imports occur.
The issues mentioned above sometimes led to duplicate code. Furthermore, if I wasn’t working on a particular file, I might not know whether it contained functions like update_domain_a. In such cases, if there is a clear consensus on using a service layer, I can simply check whether the relevant logic is implemented there.
There are also cases where domain_b needs to use business logic from domain_a. Business logic is also used in asynchronous logic, such as background tasks. As a result, I found that separating all of this into the service layer significantly improved reusability.
I realized that by introducing the service layer, we could effectively apply the widely used layered pattern. For example, currently, methods defined directly in model classes might effectively function at the repository level, and I felt it was necessary to separate these properly. This is because managing them at the model level can create dependencies when logic involving reference tables needs to be used.
pythonclass DomainA(db.model): id = db.Column(db.Integer, primary=True, autoincrement=True) name = db.Column(db.String) @classmethod def get(cls, id_): # repositoy로 분리 가능할 것 같다! return cls.query.filter(cls.id == id_).one_or_none() @classmethod def insert(cls, name): # 이것도 마찬가지! domain_a_inst = cls(name) db.session.insert(domain_a_inst) db.session.commit() return domain_a_inst.id
I also thought that 백그라운드 태스크에서 수행할 로직이 정리된 각 도메인의 폴더(?), which handles the data ETL mentioned above, could be incorporated into the service layer.
(Time to really start breaking down the legacy system...)
Based on my brief experience working on the backend with Django during pre-employment training, I know that Django also follows the MTV architectural pattern, which required strict adherence.
Compared to that, Flask is a very flexible framework. Since there’s no fixed structural pattern, it can be very convenient at times. When the service was small, I didn’t really notice this issue, but as it gradually grew in scale, a more systematic structure became increasingly necessary. Now that DDD (Domain-Driven Development) has become the standard, I think I should try to follow it as closely as possible.
I’ve been using the Flask framework for a long time and have enjoyed its flexibility; since there weren’t many points of maintenance, it wasn’t a major issue. However, I’ve come to realize that while following “standards” is the easiest and clearest way to reach consensus with my fellow developers, I seem to have overlooked this for a long time. I believe that if we develop according to each layer and ensure a clear separation of responsibilities, communication costs will be significantly reduced. (I’m actually starting to feel this already...)
As I wrote at the end of [Life’s Map](https://velog.io/@lizziechung/%EA%B8%80%EB%98%90-9%EA%B8%B0-%EC%82%B6%EC%9D%98-%EC%A7%80%EB%8F%84#-%EC%82%B6%EC%9D%98-%EC%A7%80%EB%8F%84%EB%8A%94-%EC%96%B4%EC%B0%A8%ED%94%BC-%EA
%B3%84%EC%86%8D-%EA%B7%B8%EB%A0%A4%EC%95%BC-%ED%95%9C%EB%8B%A4-keep-debugging-life%E2%99%80%EF%B8%8F), I believe I grow when I learn things I didn’t know before. So, in the end, it all comes down to “I must never stop learning.” I realized once again that I shouldn’t just let others’ words pass me by, and (in the spirit of being more proactive) I need to keep cultivating the strength to seek things out on my own.
I plan to write more posts in the future aimed at breaking free from legacy systems. To be continued...!
