Upgrade to Pro — share decks privately, control downloads, hide ads and more …

育てるアーキテクチャ:戦い抜くPythonマイクロサービスの設計と進化戦略

Avatar for fujidomoe fujidomoe
September 26, 2025

 育てるアーキテクチャ:戦い抜くPythonマイクロサービスの設計と進化戦略

PyCon2025JP, 09/27 DAY2,
育てるアーキテクチャ:戦い抜くPythonマイクロサービスの設計と進化戦略
https://2025.pycon.jp/ja/timetable/talk/CVUNWL

Avatar for fujidomoe

fujidomoe

September 26, 2025
Tweet

Other Decks in Programming

Transcript

  1. def process(data_df): jp_indices = data_df[data_df['region'] == 'JP'].index data_df.drop(data_df.index.difference(jp_indices), inplace=True) data_df['ctr']

    = data_df['clicks'] / data_df['views'] low_perf_indices = data_df[data_df['ctr'] < 0.02].index data_df.drop(low_perf_indices, inplace=True) data_df['is_high_performer'] = True 存在保証のない列でフィルタ &中間データの生成 破壊的な操作 存在保証のない列を用いて演算
  2. kwargsとは? def create_profile(name, **options): profile = {"name": name} # デフォルト値を設定

    profile["age"] = options.get("age", "未設定") profile["email"] = options.get("email", "未設定") profile["location"] = options.get("location", "未設定") return profile
  3. kwargs編② # extract.py def extract_data(user_id, **kwargs): start_date = kwargs.get("start_date", '2025-09-27')

    end_date = kwargs.get("end_date", datetime.now()) include_deleted = kwargs.get('include_deleted', False) batch_size = kwargs.get('batch_size', 1000) retry_count = kwargs.get('retry_count', 3) # さらに続く... # transform.py def transform_data(data, **kwargs): normalize = kwargs.get('normalize', True) remove_duplicates = kwargs.get('remove_duplicates', True) # まだまだ続く...
  4. kwargs編② # extract.py def extract_data(user_id, **kwargs): start_date = kwargs.get("start_date", '2025-09-27')

    end_date = kwargs.get("end_date", datetime.now()) include_deleted = kwargs.get('include_deleted', False) batch_size = kwargs.get('batch_size', 1000) retry_count = kwargs.get('retry_count', 3) # さらに続く... 😱 どんなパラメータを渡せるの? 😱 利用可能なオプション覚えておくの?
  5. Pandas DataFrameを利用した例 import pandas as pd from sqlalchemy import create_engine

    db_engine = create_engine("省略") # 1. SQLクエリを準備 sql_query = "SELECT user_id, product_name, price FROM sales_data WHERE price > 5000;" # 2. SQLを実行し、結果を直接DataFrameに読み込む df = pd.read_sql(sql_query, db_engine) # 3. 取得したDataFrameを表示 print(df) # 例:価格(price)が10000より大きいデータを抽出 high_price_df = df[df['price'] > 10000]
  6. Pandas Dataframe編 def process(self, target_df: pd.DataFrame, source_df: pd.DataFrame) -> pd.DataFrame:

    time_total_df = (target_df.groupby("datehour")[["count"]] .sum().reset_index().rename(columns={"count":"total_count"}) ) prorating_target_df = pd.merge(target_df, time_total_df, on="datehour", how="left") prorating_target_df = pd.merge( prorating_target_df, source_df.rename(columns={"count": "source_count"}), on="datehour", how="outer", ) df = prorating_target_df[["datehour","prefecture","prorated"]].rename(columns={"prorated": "count"}) # この後手続き処理が100行ほど続く...
  7. Pandas Dataframe編 def process(self, target_df: pd.DataFrame, source_df: pd.DataFrame) -> pd.DataFrame:

    time_total_df = (target_df.groupby("datehour")[["count"]] .sum().reset_index().rename(columns={"count":"total_count"}) ) prorating_target_df = pd.merge( prorating_target_df, source_df.rename(columns={"count": "source_count"}), on="datehour", how="outer", ) df = prorating_target_df[["datehour","prefecture","prorated"]].rename(columns={"prorated": "count"}) # この後手続き処理が100行ほど続く... 😇これはこれで気になる
  8. Pandas Dataframe編 def process(self, target_df: pd.DataFrame, source_df: pd.DataFrame) -> pd.DataFrame:

    time_total_df = (target_df.groupby("datehour")[["count"]] .sum().reset_index().rename(columns={"count":"total_count"}) ) # この後手続き処理が100行ほど続く... 😱引数も戻り値もDataFrame
  9. Pandas Dataframe編 time_total_df = (target_df.groupby("datehour")[["count"]] .sum().reset_index().rename(columns={"count":"total_count"}) ) prorating_target_df = pd.merge(target_df,

    time_total_df, on="datehour", how="left") prorating_target_df = pd.merge( prorating_target_df, source_df.rename(columns={"count": "source_count"}), on="datehour", how="outer", ) df = prorating_target_df[["datehour","prefecture","prorated"]].rename(columns={"prorated": "count"}) メソッドの中身全体として 😱 存在保証のないカラムにアクセス 😱 各操作がDataFrameの構造を変更
  10. アーキテクチャ不在(グローバル関数羅列) class RDBConnection: def __init__(self): self.con = pymysql.connect( "省略") def

    execute_read_query (self, query: str): return result def find_report_by_date_query (date: str) -> str: q = f"""SELECT report_id FROM ...""" return q def find_report_by_date (date: str) -> list[int]: db = RDBConnection() q = find_report_by_date_query (date) res = db.execute_read_query (q) return [int(r[0]) for r in res] def find_report_by_id_query (id: int) -> list[int]: # 省略 def find_report_by_id_query (id: int) -> str: # 省略 # find_report_by_dateと同じようなパターンが沢山存在する ....
  11. アーキテクチャ不在(グローバル関数羅列) def find_report_by_date (date: str) -> list[int]: db = RDBConnection()

    q = find_report_by_date_query (date) res = db.execute_read_query (q) return [int(r[0]) for r in res] 毎回コネクション ビジネスロジック(データ変換)、SQL生成、データ入出力
  12. アーキテクチャ不在(グローバル関数羅列) def find_report_by_date_query(date: str) -> str: q = f"""SELECT report_id

    FROM ...""" return q def find_report_by_date(date: str) -> list[int]: db = RDBConnection() q = find_report_by_date_query(date) res = db.execute_read_query(q) return [int(r[0]) for r in res] 必ずセット
  13. Step by Stepアプローチ • Step1 : 型ヒントを追加してみる • Step2 :

    Pydanticで1つの関数の引数を整理 • Step3 : レイヤー分離を試す • Step4 : チーム全体で改善の輪をひろげる
  14. Step by Stepアプローチ • Step1 : 型ヒントを追加してみる(割愛) • Step2 :

    Pydanticで1つの関数の引数を整理 • Step3 : レイヤー分離を試す • Step4 : チーム全体で改善の輪をひろげる
  15. before # extract.py def extract_data(user_id, **kwargs): start_date = kwargs.get("start_date", '2025-09-27')

    end_date = kwargs.get("end_date", datetime.now()) include_deleted = kwargs.get('include_deleted', False) batch_size = kwargs.get('batch_size', 1000) retry_count = kwargs.get('retry_count', 3) # さらに続く... 😱 どんなパラメータを渡せるの? 😱 利用可能なオプション覚えておくの?
  16. After from pydantic import BaseModel, Field from datetime import datetime

    class Param(BaseModel): user_id: int start_date: datetime = Field(default=datetime(2025, 9, 27)) end_date: datetime = Field(default_factory=datetime.now) include_deleted: bool = False batch_size: int = Field(default=1000, gt=0, le=10000) retry_count: int = Field(default=3, ge=1, le=5) def extract_data(param: Param): # 何が渡ってくる型が明確 😀 # IDEの補完が効く! 😀 # validな値が渡ってくる 😀
  17. 正常想定の値によるインスタンス生成 : 生成成功 In [1]: from pydantic import BaseModel, Field

    ...: from datetime import datetime ...: class Param(BaseModel): ...: user_id: int ...: start_date: datetime = Field(default=datetime(2025, 9, 27)) ...: end_date: datetime = Field(default_factory=datetime.now) ...: include_deleted: bool = False ...: batch_size: int = Field(default=1000, gt=0, le=10000) ...: retry_count: int = Field(default=3, ge=1, le=5) In [2]: p = Param(user_id=123, start_date="2025-09-27T14:50:00") In [3]: p Out[3]: Param(user_id=123, start_date=datetime.datetime(2025, 9, 27, 14, 50), end_date=datetime.datetime(2025, 9, 14, 18, 10, 6, 756829), include_deleted=False, batch_size=1000, retry_count=3) 成功
  18. 不正な値によるインスタンス生成 : 生成失敗 ---->1 param = Param( # 不正な値を渡すと 2

    user_id="not_a_number" , # ❌ ValidationError 3 batch_size=100000) # ❌ batch_size must be <= 10000 File /usr/local/lib/python3.12/site-packages/pydantic/main.py:253, in BaseModel. __init__(self, **data) --> 253 validated_self = self.__pydantic_validator__.validate_python(data, self_instance =self) 254 if self is not validated_self: 255 warnings.warn( "省略", stacklevel=2) ValidationError : 2 validation errors for Param user_id Input should be a valid integer , unable to parse string as an integer [ type=int_parsing , input_value= 'not_a_number' , input_type= str] For further information visit https://errors.pydantic.dev /2.11/v/int_parsing batch_size "省略” 例外
  19. Pydantic デフォルト設定 In [1]: from pydantic import BaseModel, ConfigDict In

    [2]: class User(BaseModel): ...: id: int ...: name: str In [3]: u = User(id=123, name="PyCon2025") In [4]: u.name="hoge" In [5]: u Out[5]: User(id=123, name='hoge') 項目値変更可能 インスタンス生成成功
  20. Pydantic イミュータブル設定 In [1]: from pydantic import BaseModel, ConfigDict ...:

    class ImmutableModel(BaseModel): ...: model_config = ConfigDict(frozen=True) # イミュータブル In [2]:class User(ImmutableModel): ...: id: int ...: name: str In [3]: u = User(id=123, name="PyCon2025") In [4]: u.name = "hoge" --------------------------------------------------------------------------- ValidationError Traceback (most recent call last) Cell In[4], line 1 ----> 1 u.name = "hoge" インスタンス生成成功 項目値変更すると例外
  21. 型ヒントのみ In [1]: from dataclasses import dataclass ...: @dataclass ...:

    class Param: ...: name: str ...: age: int ...: In [2]: p = Param(name="PyCon2025", age="15歳") In [3]: p Out[3]: Param(name='PyCon2025', age='15歳') ”15歳”という文字列がそのまま格納 🤔
  22. 他の選択肢はあるの? import pandas as pd import pandera as pa #

    「name」と「age」カラムが必須であるスキーマを定義 schema = pa.DataFrameSchema({ "name": pa.Column(str), # ageは0以上の整数 "age": pa.Column(int, pa.Check.ge(0)) }) invalid_df = pd.DataFrame({ "name": ["Taro", "Jiro"] # "age"カラムが欠けている! }) try: schema.validate(invalid_df) except pa.errors.SchemaError as e: print(e)
  23. Step by Stepアプローチ • Step1 : 型ヒントを追加してみる(割愛) • Step2 :

    Pydanticで1つの関数の引数を整理 • Step3 : レイヤー分離を試す • Step4 : チーム全体で改善の輪をひろげる
  24. class IReportRepository(ABC): @abstractmethod def find_by_report_id(self, report_id: int) -> Report |

    None: pass class ReportRepository(IReportRepository): def __init__(self, session: Session): self._session = session # DB接続は外部から注入 def find_by_report_id(self, report_id: int) -> Report | None: x = self._session.query(ReportDTO).filter_by(id=report_id).one() #try except省略 return Report(report_id=x.id, report_name=x.report_name, expired_at=x.expired_at) class ReportUseCase: def __init__(self, repository: IReportRepository): self._repository = repository def find_report(self, report_id: int): report = self._repository.find_by_report_id(report_id) if not report: return None ... 省略 ... 抽象基底クラスを作成する仕組み 継承先にメソッド実装を強制
  25. class IReportRepository(ABC): @abstractmethod def find_by_report_id(self, report_id: int) -> Report |

    None: pass class ReportRepository(IReportRepository): def __init__(self, session: Session): self._session = session # DB接続は外部から注入 def find_by_report_id(self, report_id: int) -> Report | None: x = self._session.query(ReportDTO).filter_by(id=report_id).one() #try except省略 return Report(report_id=x.id, report_name=x.report_name, expired_at=x.expired_at) class ReportUseCase: def __init__(self, repository: IReportRepository): self._repository = repository def find_report(self, report_id: int): report = self._repository.find_by_report_id(report_id) if not report: return None ... 省略 ... 継承元の@abstractmethod由来で実装を強制
  26. before ## before: 混沌 query.py (1000行超え) ├── find_report_by_date_query() ├── find_report_by_date()

    ├── find_report_by_id_query() ├── find_report_by_id() └── ... (延々と続く)
  27. after ## After: レイヤー構造 ├── cli # エントリーポイント ├── domain

    │ ├── entity │ └── repository # interfaceのみ ├── infra # DB接続など │ └── mysql │ ├── model │ └── repository # interfaceの実装
  28. Step by Stepアプローチ • Step1 : 型ヒントを追加してみる(割愛) • Step2 :

    Pydanticで1つの関数の引数を整理 • Step3 : レイヤー分離を試す • Step4 : チーム全体で改善の輪をひろげる
  29. • 改善ポイントを公開の場で議論 ◦ Slack/Teamsのpublic channel or GitHub ◦ 定例でのアジェンダ化 ◦

    段階的な合意形成 ▪ Step1 : 課題の共有 ▪ Step2 : 解決策の提案 ▪ Step3 : 小さく試す ②透明性のある進め方
  30. Step by Stepアプローチ(おさらい) • Step1 : 型ヒントを追加してみる • Step2 :

    Pydanticで1つの関数の引数を整理 • Step3 : レイヤー分離を試す • Step4 : チーム全体で改善の輪をひろげる