[{"data":1,"prerenderedAt":1412},["ShallowReactive",2],{"article-\u002Farticles\u002Fhexagonal-architecture-dotnet":3},{"id":4,"title":5,"body":6,"date":1398,"description":167,"excerpt":1399,"extension":1400,"meta":1401,"navigation":257,"path":1402,"seo":1403,"stem":1404,"tags":1405,"__hash__":1411},"articles\u002Farticles\u002Fhexagonal-architecture-dotnet.md","六角形架構（Ports & Adapters）在 .NET 的實作：讓圖片從本機換到 S3 不用改一行業務程式",{"type":7,"value":8,"toc":1387},"minimark",[9,13,22,46,49,53,63,70,132,136,143,161,546,550,553,1019,1025,1081,1091,1094,1097,1105,1119,1129,1132,1138,1330,1339,1345,1348,1359,1363,1366,1377,1380,1383],[10,11,12],"h2",{"id":12},"傳統分層架構的問題",[14,15,16,17,21],"p",{},"最常見的三層架構是 Presentation → Business Logic → Data Access，依賴一路往下。問題在於",[18,19,20],"strong",{},"業務邏輯依賴了資料存取層","：",[23,24,25,37,43],"ul",{},[26,27,28,29,33,34],"li",{},"業務邏輯直接 ",[30,31,32],"code",{},"new"," 或注入具體的 Repository 類別，裡面是 EF Core 的 ",[30,35,36],{},"DbContext",[26,38,39,40],{},"要上傳圖片，業務邏輯裡直接呼叫 AWS SDK 的 ",[30,41,42],{},"PutObjectAsync",[26,44,45],{},"要呼叫 GPT，業務邏輯裡直接組 HTTP 請求",[14,47,48],{},"結果是：想測試「新增訊息時，聊天室的標題要自動以第一句話命名」這個規則，得先準備資料庫、S3、OpenAI 的金鑰。想把圖片從 S3 換成本機磁碟（開發環境不想連 AWS），得修改業務邏輯的程式碼。",[10,50,52],{"id":51},"六角形架構核心在中間外部世界在外圍","六角形架構：核心在中間，外部世界在外圍",[14,54,55,58,59,62],{},[18,56,57],{},"六角形架構（Hexagonal Architecture）","，又稱 ",[18,60,61],{},"Ports & Adapters","，由 Alistair Cockburn 提出。六角形本身沒有特別的意義，只是畫起來比圓形容易標出很多個「面」，每一面代表一種和外界溝通的方式。",[14,64,65],{},[66,67],"img",{"alt":68,"src":69},"六角形架構示意圖：中心是 Domain（ChatRoom 聚合根與領域事件），外圍是 Application UseCase；左側 Driving Adapter（Web API Controller、SignalR Hub、單元測試）呼叫核心定義的 In Port；右側 Out Port（IChatRoomRepository、IImageStorage、IChatCompletion）由 Driven Adapter 實作（EF Core SQL Server、Local 或 S3 圖片儲存、OpenAI API 或測試用假物件）；所有箭頭都是依賴方向，Adapter 依賴核心定義的 Port，核心不認識任何 Adapter","\u002Fimages\u002Farticles\u002Fhexagonal-architecture.svg",[23,71,72,82,112],{},[26,73,74,77,78,81],{},[18,75,76],{},"核心（Core）","：Domain（實體、聚合根、領域事件、商業規則）與 Application（UseCase，也就是應用程式提供的功能）。核心",[18,79,80],{},"只包含業務","，不引用任何技術框架",[26,83,84,87,88,91,92],{},[18,85,86],{},"Port（埠）","：核心定義的",[18,89,90],{},"介面","，描述「我需要什麼」或「我提供什麼」\n",[23,93,94,103],{},[26,95,96,99,100],{},[18,97,98],{},"In Port（Driving Port）","：核心對外提供的功能，例如 ",[30,101,102],{},"IChatRoomService.CreateAsync()",[26,104,105,108,109],{},[18,106,107],{},"Out Port（Driven Port）","：核心需要外部提供的能力，例如「我需要一個能儲存圖片的東西」",[30,110,111],{},"IImageStorage",[26,113,114,117,118],{},[18,115,116],{},"Adapter（轉接器）","：把外部技術接到 Port 上的實作\n",[23,119,120,126],{},[26,121,122,125],{},[18,123,124],{},"Driving Adapter","：呼叫核心的一方，例如 Web API Controller、SignalR Hub、排程、單元測試",[26,127,128,131],{},[18,129,130],{},"Driven Adapter","：被核心呼叫的一方，例如 EF Core Repository、S3 圖片儲存、OpenAI 用戶端",[10,133,135],{"id":134},"最重要的規則依賴方向往內","最重要的規則：依賴方向往內",[14,137,138,139,142],{},"六角形架構的核心規則只有一條：",[18,140,141],{},"所有依賴都指向核心，核心不依賴任何外部技術","。",[14,144,145,146,149,150,153,154,157,158,160],{},"Out Port 的介面",[18,147,148],{},"定義在核心裡","，實作",[18,151,152],{},"放在外圍","。這就是",[18,155,156],{},"依賴反轉原則（Dependency Inversion Principle）","：核心說「我需要一個 ",[30,159,111],{},"」，至於它是本機磁碟還是 S3，核心完全不知道，也不在乎。",[162,163,168],"pre",{"className":164,"code":165,"language":166,"meta":167,"style":167},"language-csharp shiki shiki-themes github-dark","\u002F\u002F 核心：定義 Port（介面放在 UseCase 專案中）\npublic interface IImageStorage\n{\n    Task\u003Cstring> SaveAsync(string fileName, Stream content, CancellationToken cancellationToken);\n}\n\n\u002F\u002F 核心：UseCase 只依賴 Port\npublic class AddImageMessageService\n{\n    private readonly IImageStorage _imageStorage;\n    private readonly IChatRoomRepository _repository;\n\n    public AddImageMessageService(IImageStorage imageStorage, IChatRoomRepository repository)\n    {\n        _imageStorage = imageStorage;\n        _repository = repository;\n    }\n\n    public async Task HandleAsync(Guid chatRoomId, string prompt, Stream image, CancellationToken ct)\n    {\n        var chatRoom = await _repository.GetAsync(chatRoomId, ct);\n        var url = await _imageStorage.SaveAsync($\"{Guid.NewGuid()}.png\", image, ct);\n\n        chatRoom.AddImageMessage(prompt, url);   \u002F\u002F 商業規則在聚合根裡\n        await _repository.SaveAsync(chatRoom, ct);\n    }\n}\n","csharp","",[30,169,170,179,193,200,246,252,259,265,276,281,299,314,319,346,352,364,375,381,386,431,436,460,503,508,523,536,541],{"__ignoreMap":167},[171,172,175],"span",{"class":173,"line":174},"line",1,[171,176,178],{"class":177},"sAwPA","\u002F\u002F 核心：定義 Port（介面放在 UseCase 專案中）\n",[171,180,182,186,189],{"class":173,"line":181},2,[171,183,185],{"class":184},"snl16","public",[171,187,188],{"class":184}," interface",[171,190,192],{"class":191},"svObZ"," IImageStorage\n",[171,194,196],{"class":173,"line":195},3,[171,197,199],{"class":198},"s95oV","{\n",[171,201,203,206,209,212,215,218,221,223,226,229,232,235,237,240,243],{"class":173,"line":202},4,[171,204,205],{"class":191},"    Task",[171,207,208],{"class":198},"\u003C",[171,210,211],{"class":184},"string",[171,213,214],{"class":198},"> ",[171,216,217],{"class":191},"SaveAsync",[171,219,220],{"class":198},"(",[171,222,211],{"class":184},[171,224,225],{"class":191}," fileName",[171,227,228],{"class":198},", ",[171,230,231],{"class":191},"Stream",[171,233,234],{"class":191}," content",[171,236,228],{"class":198},[171,238,239],{"class":191},"CancellationToken",[171,241,242],{"class":191}," cancellationToken",[171,244,245],{"class":198},");\n",[171,247,249],{"class":173,"line":248},5,[171,250,251],{"class":198},"}\n",[171,253,255],{"class":173,"line":254},6,[171,256,258],{"emptyLinePlaceholder":257},true,"\n",[171,260,262],{"class":173,"line":261},7,[171,263,264],{"class":177},"\u002F\u002F 核心：UseCase 只依賴 Port\n",[171,266,268,270,273],{"class":173,"line":267},8,[171,269,185],{"class":184},[171,271,272],{"class":184}," class",[171,274,275],{"class":191}," AddImageMessageService\n",[171,277,279],{"class":173,"line":278},9,[171,280,199],{"class":198},[171,282,284,287,290,293,296],{"class":173,"line":283},10,[171,285,286],{"class":184},"    private",[171,288,289],{"class":184}," readonly",[171,291,292],{"class":191}," IImageStorage",[171,294,295],{"class":191}," _imageStorage",[171,297,298],{"class":198},";\n",[171,300,302,304,306,309,312],{"class":173,"line":301},11,[171,303,286],{"class":184},[171,305,289],{"class":184},[171,307,308],{"class":191}," IChatRoomRepository",[171,310,311],{"class":191}," _repository",[171,313,298],{"class":198},[171,315,317],{"class":173,"line":316},12,[171,318,258],{"emptyLinePlaceholder":257},[171,320,322,325,328,330,332,335,337,340,343],{"class":173,"line":321},13,[171,323,324],{"class":184},"    public",[171,326,327],{"class":191}," AddImageMessageService",[171,329,220],{"class":198},[171,331,111],{"class":191},[171,333,334],{"class":191}," imageStorage",[171,336,228],{"class":198},[171,338,339],{"class":191},"IChatRoomRepository",[171,341,342],{"class":191}," repository",[171,344,345],{"class":198},")\n",[171,347,349],{"class":173,"line":348},14,[171,350,351],{"class":198},"    {\n",[171,353,355,358,361],{"class":173,"line":354},15,[171,356,357],{"class":198},"        _imageStorage ",[171,359,360],{"class":184},"=",[171,362,363],{"class":198}," imageStorage;\n",[171,365,367,370,372],{"class":173,"line":366},16,[171,368,369],{"class":198},"        _repository ",[171,371,360],{"class":184},[171,373,374],{"class":198}," repository;\n",[171,376,378],{"class":173,"line":377},17,[171,379,380],{"class":198},"    }\n",[171,382,384],{"class":173,"line":383},18,[171,385,258],{"emptyLinePlaceholder":257},[171,387,389,391,394,397,400,402,405,408,410,412,415,417,419,422,424,426,429],{"class":173,"line":388},19,[171,390,324],{"class":184},[171,392,393],{"class":184}," async",[171,395,396],{"class":191}," Task",[171,398,399],{"class":191}," HandleAsync",[171,401,220],{"class":198},[171,403,404],{"class":191},"Guid",[171,406,407],{"class":191}," chatRoomId",[171,409,228],{"class":198},[171,411,211],{"class":184},[171,413,414],{"class":191}," prompt",[171,416,228],{"class":198},[171,418,231],{"class":191},[171,420,421],{"class":191}," image",[171,423,228],{"class":198},[171,425,239],{"class":191},[171,427,428],{"class":191}," ct",[171,430,345],{"class":198},[171,432,434],{"class":173,"line":433},20,[171,435,351],{"class":198},[171,437,439,442,445,448,451,454,457],{"class":173,"line":438},21,[171,440,441],{"class":184},"        var",[171,443,444],{"class":191}," chatRoom",[171,446,447],{"class":184}," =",[171,449,450],{"class":184}," await",[171,452,453],{"class":198}," _repository.",[171,455,456],{"class":191},"GetAsync",[171,458,459],{"class":198},"(chatRoomId, ct);\n",[171,461,463,465,468,470,472,475,477,479,483,486,488,491,494,497,500],{"class":173,"line":462},22,[171,464,441],{"class":184},[171,466,467],{"class":191}," url",[171,469,447],{"class":184},[171,471,450],{"class":184},[171,473,474],{"class":198}," _imageStorage.",[171,476,217],{"class":191},[171,478,220],{"class":198},[171,480,482],{"class":481},"sU2Wk","$\"",[171,484,485],{"class":481},"{",[171,487,404],{"class":198},[171,489,490],{"class":481},".",[171,492,493],{"class":191},"NewGuid",[171,495,496],{"class":481},"()}",[171,498,499],{"class":481},".png\"",[171,501,502],{"class":198},", image, ct);\n",[171,504,506],{"class":173,"line":505},23,[171,507,258],{"emptyLinePlaceholder":257},[171,509,511,514,517,520],{"class":173,"line":510},24,[171,512,513],{"class":198},"        chatRoom.",[171,515,516],{"class":191},"AddImageMessage",[171,518,519],{"class":198},"(prompt, url);   ",[171,521,522],{"class":177},"\u002F\u002F 商業規則在聚合根裡\n",[171,524,526,529,531,533],{"class":173,"line":525},25,[171,527,528],{"class":184},"        await",[171,530,453],{"class":198},[171,532,217],{"class":191},[171,534,535],{"class":198},"(chatRoom, ct);\n",[171,537,539],{"class":173,"line":538},26,[171,540,380],{"class":198},[171,542,544],{"class":173,"line":543},27,[171,545,251],{"class":198},[10,547,549],{"id":548},"同一個-port兩種-adapter","同一個 Port，兩種 Adapter",[14,551,552],{},"圖片儲存有兩種實作，各自放在獨立的專案：",[162,554,556],{"className":164,"code":555,"language":166,"meta":167,"style":167},"\u002F\u002F Adapter：本機檔案系統（開發、測試環境）\npublic class LocalImageStorage : IImageStorage\n{\n    private readonly string _root;\n    public LocalImageStorage(IOptions\u003CLocalStorageOptions> options) => _root = options.Value.Path;\n\n    public async Task\u003Cstring> SaveAsync(string fileName, Stream content, CancellationToken ct)\n    {\n        var path = Path.Combine(_root, fileName);\n        await using var file = File.Create(path);\n        await content.CopyToAsync(file, ct);\n        return $\"\u002Fimages\u002F{fileName}\";\n    }\n}\n\n\u002F\u002F Adapter：AWS S3（正式環境）\npublic class S3ImageStorage : IImageStorage\n{\n    private readonly IAmazonS3 _s3;\n    private readonly string _bucket;\n\n    public S3ImageStorage(IAmazonS3 s3, IOptions\u003CS3StorageOptions> options)\n    {\n        _s3 = s3;\n        _bucket = options.Value.Bucket;\n    }\n\n    public async Task\u003Cstring> SaveAsync(string fileName, Stream content, CancellationToken ct)\n    {\n        await _s3.PutObjectAsync(new PutObjectRequest\n        {\n            BucketName = _bucket,\n            Key = fileName,\n            InputStream = content\n        }, ct);\n        return $\"https:\u002F\u002F{_bucket}.s3.amazonaws.com\u002F{fileName}\";\n    }\n}\n",[30,557,558,563,578,582,596,631,635,671,675,693,717,730,751,755,759,763,768,781,785,799,812,816,845,849,859,869,873,877,914,919,936,942,953,964,975,981,1009,1014],{"__ignoreMap":167},[171,559,560],{"class":173,"line":174},[171,561,562],{"class":177},"\u002F\u002F Adapter：本機檔案系統（開發、測試環境）\n",[171,564,565,567,569,572,575],{"class":173,"line":181},[171,566,185],{"class":184},[171,568,272],{"class":184},[171,570,571],{"class":191}," LocalImageStorage",[171,573,574],{"class":198}," : ",[171,576,577],{"class":191},"IImageStorage\n",[171,579,580],{"class":173,"line":195},[171,581,199],{"class":198},[171,583,584,586,588,591,594],{"class":173,"line":202},[171,585,286],{"class":184},[171,587,289],{"class":184},[171,589,590],{"class":184}," string",[171,592,593],{"class":191}," _root",[171,595,298],{"class":198},[171,597,598,600,602,604,607,609,612,614,617,620,623,626,628],{"class":173,"line":248},[171,599,324],{"class":184},[171,601,571],{"class":191},[171,603,220],{"class":198},[171,605,606],{"class":191},"IOptions",[171,608,208],{"class":198},[171,610,611],{"class":191},"LocalStorageOptions",[171,613,214],{"class":198},[171,615,616],{"class":191},"options",[171,618,619],{"class":198},") ",[171,621,622],{"class":184},"=>",[171,624,625],{"class":198}," _root ",[171,627,360],{"class":184},[171,629,630],{"class":198}," options.Value.Path;\n",[171,632,633],{"class":173,"line":254},[171,634,258],{"emptyLinePlaceholder":257},[171,636,637,639,641,643,645,647,649,651,653,655,657,659,661,663,665,667,669],{"class":173,"line":261},[171,638,324],{"class":184},[171,640,393],{"class":184},[171,642,396],{"class":191},[171,644,208],{"class":198},[171,646,211],{"class":184},[171,648,214],{"class":198},[171,650,217],{"class":191},[171,652,220],{"class":198},[171,654,211],{"class":184},[171,656,225],{"class":191},[171,658,228],{"class":198},[171,660,231],{"class":191},[171,662,234],{"class":191},[171,664,228],{"class":198},[171,666,239],{"class":191},[171,668,428],{"class":191},[171,670,345],{"class":198},[171,672,673],{"class":173,"line":267},[171,674,351],{"class":198},[171,676,677,679,682,684,687,690],{"class":173,"line":278},[171,678,441],{"class":184},[171,680,681],{"class":191}," path",[171,683,447],{"class":184},[171,685,686],{"class":198}," Path.",[171,688,689],{"class":191},"Combine",[171,691,692],{"class":198},"(_root, fileName);\n",[171,694,695,697,700,703,706,708,711,714],{"class":173,"line":283},[171,696,528],{"class":184},[171,698,699],{"class":184}," using",[171,701,702],{"class":184}," var",[171,704,705],{"class":191}," file",[171,707,447],{"class":184},[171,709,710],{"class":198}," File.",[171,712,713],{"class":191},"Create",[171,715,716],{"class":198},"(path);\n",[171,718,719,721,724,727],{"class":173,"line":301},[171,720,528],{"class":184},[171,722,723],{"class":198}," content.",[171,725,726],{"class":191},"CopyToAsync",[171,728,729],{"class":198},"(file, ct);\n",[171,731,732,735,738,740,743,746,749],{"class":173,"line":316},[171,733,734],{"class":184},"        return",[171,736,737],{"class":481}," $\"\u002Fimages\u002F",[171,739,485],{"class":481},[171,741,742],{"class":198},"fileName",[171,744,745],{"class":481},"}",[171,747,748],{"class":481},"\"",[171,750,298],{"class":198},[171,752,753],{"class":173,"line":321},[171,754,380],{"class":198},[171,756,757],{"class":173,"line":348},[171,758,251],{"class":198},[171,760,761],{"class":173,"line":354},[171,762,258],{"emptyLinePlaceholder":257},[171,764,765],{"class":173,"line":366},[171,766,767],{"class":177},"\u002F\u002F Adapter：AWS S3（正式環境）\n",[171,769,770,772,774,777,779],{"class":173,"line":377},[171,771,185],{"class":184},[171,773,272],{"class":184},[171,775,776],{"class":191}," S3ImageStorage",[171,778,574],{"class":198},[171,780,577],{"class":191},[171,782,783],{"class":173,"line":383},[171,784,199],{"class":198},[171,786,787,789,791,794,797],{"class":173,"line":388},[171,788,286],{"class":184},[171,790,289],{"class":184},[171,792,793],{"class":191}," IAmazonS3",[171,795,796],{"class":191}," _s3",[171,798,298],{"class":198},[171,800,801,803,805,807,810],{"class":173,"line":433},[171,802,286],{"class":184},[171,804,289],{"class":184},[171,806,590],{"class":184},[171,808,809],{"class":191}," _bucket",[171,811,298],{"class":198},[171,813,814],{"class":173,"line":438},[171,815,258],{"emptyLinePlaceholder":257},[171,817,818,820,822,824,827,830,832,834,836,839,841,843],{"class":173,"line":462},[171,819,324],{"class":184},[171,821,776],{"class":191},[171,823,220],{"class":198},[171,825,826],{"class":191},"IAmazonS3",[171,828,829],{"class":191}," s3",[171,831,228],{"class":198},[171,833,606],{"class":191},[171,835,208],{"class":198},[171,837,838],{"class":191},"S3StorageOptions",[171,840,214],{"class":198},[171,842,616],{"class":191},[171,844,345],{"class":198},[171,846,847],{"class":173,"line":505},[171,848,351],{"class":198},[171,850,851,854,856],{"class":173,"line":510},[171,852,853],{"class":198},"        _s3 ",[171,855,360],{"class":184},[171,857,858],{"class":198}," s3;\n",[171,860,861,864,866],{"class":173,"line":525},[171,862,863],{"class":198},"        _bucket ",[171,865,360],{"class":184},[171,867,868],{"class":198}," options.Value.Bucket;\n",[171,870,871],{"class":173,"line":538},[171,872,380],{"class":198},[171,874,875],{"class":173,"line":543},[171,876,258],{"emptyLinePlaceholder":257},[171,878,880,882,884,886,888,890,892,894,896,898,900,902,904,906,908,910,912],{"class":173,"line":879},28,[171,881,324],{"class":184},[171,883,393],{"class":184},[171,885,396],{"class":191},[171,887,208],{"class":198},[171,889,211],{"class":184},[171,891,214],{"class":198},[171,893,217],{"class":191},[171,895,220],{"class":198},[171,897,211],{"class":184},[171,899,225],{"class":191},[171,901,228],{"class":198},[171,903,231],{"class":191},[171,905,234],{"class":191},[171,907,228],{"class":198},[171,909,239],{"class":191},[171,911,428],{"class":191},[171,913,345],{"class":198},[171,915,917],{"class":173,"line":916},29,[171,918,351],{"class":198},[171,920,922,924,927,929,931,933],{"class":173,"line":921},30,[171,923,528],{"class":184},[171,925,926],{"class":198}," _s3.",[171,928,42],{"class":191},[171,930,220],{"class":198},[171,932,32],{"class":184},[171,934,935],{"class":191}," PutObjectRequest\n",[171,937,939],{"class":173,"line":938},31,[171,940,941],{"class":198},"        {\n",[171,943,945,948,950],{"class":173,"line":944},32,[171,946,947],{"class":198},"            BucketName ",[171,949,360],{"class":184},[171,951,952],{"class":198}," _bucket,\n",[171,954,956,959,961],{"class":173,"line":955},33,[171,957,958],{"class":198},"            Key ",[171,960,360],{"class":184},[171,962,963],{"class":198}," fileName,\n",[171,965,967,970,972],{"class":173,"line":966},34,[171,968,969],{"class":198},"            InputStream ",[171,971,360],{"class":184},[171,973,974],{"class":198}," content\n",[171,976,978],{"class":173,"line":977},35,[171,979,980],{"class":198},"        }, ct);\n",[171,982,984,986,989,991,994,996,999,1001,1003,1005,1007],{"class":173,"line":983},36,[171,985,734],{"class":184},[171,987,988],{"class":481}," $\"https:\u002F\u002F",[171,990,485],{"class":481},[171,992,993],{"class":198},"_bucket",[171,995,745],{"class":481},[171,997,998],{"class":481},".s3.amazonaws.com\u002F",[171,1000,485],{"class":481},[171,1002,742],{"class":198},[171,1004,745],{"class":481},[171,1006,748],{"class":481},[171,1008,298],{"class":198},[171,1010,1012],{"class":173,"line":1011},37,[171,1013,380],{"class":198},[171,1015,1017],{"class":173,"line":1016},38,[171,1018,251],{"class":198},[14,1020,1021,1022,21],{},"要用哪一個，",[18,1023,1024],{},"只在程式進入點決定",[162,1026,1028],{"className":164,"code":1027,"language":166,"meta":167,"style":167},"if (builder.Configuration[\"AWS_BUCKET\"] is not null)\n    builder.Services.AddS3ImageStorage(builder.Configuration);\nelse\n    builder.Services.AddLocalImageStorage(builder.Configuration);\n",[30,1029,1030,1056,1067,1072],{"__ignoreMap":167},[171,1031,1032,1035,1038,1041,1044,1047,1050,1054],{"class":173,"line":174},[171,1033,1034],{"class":184},"if",[171,1036,1037],{"class":198}," (builder.Configuration[",[171,1039,1040],{"class":481},"\"AWS_BUCKET\"",[171,1042,1043],{"class":198},"] ",[171,1045,1046],{"class":184},"is",[171,1048,1049],{"class":184}," not",[171,1051,1053],{"class":1052},"sDLfK"," null",[171,1055,345],{"class":198},[171,1057,1058,1061,1064],{"class":173,"line":181},[171,1059,1060],{"class":198},"    builder.Services.",[171,1062,1063],{"class":191},"AddS3ImageStorage",[171,1065,1066],{"class":198},"(builder.Configuration);\n",[171,1068,1069],{"class":173,"line":195},[171,1070,1071],{"class":184},"else\n",[171,1073,1074,1076,1079],{"class":173,"line":202},[171,1075,1060],{"class":198},[171,1077,1078],{"class":191},"AddLocalImageStorage",[171,1080,1066],{"class":198},[14,1082,1083,1084,1087,1088,142],{},"換儲存方式時，",[30,1085,1086],{},"AddImageMessageService"," 和其他所有業務程式",[18,1089,1090],{},"一行都不用改",[10,1092,1093],{"id":1093},"專案怎麼切",[14,1095,1096],{},"用專案（csproj）的參考關係，讓編譯器幫忙守住依賴方向：",[162,1098,1103],{"className":1099,"code":1101,"language":1102,"meta":167},[1100],"language-text","NexusGPT.Entities                         ← Domain：聚合根、領域事件（不參考任何專案）\nNexusGPT.UseCase                          ← Application：UseCase + Port 介面（只參考 Entities）\nNexusGPT.Adapter.Out                      ← Driven Adapter：EF Core Repository（參考 UseCase）\nNexusGPT.Adapter.Out.ImageStorage.Local   ← Driven Adapter：本機圖片儲存\nNexusGPT.Adapter.Out.ImageStorage.S3      ← Driven Adapter：S3 圖片儲存\nNexusGPT.WebApplication                   ← Driving Adapter + 組裝：Controller、Hub、DI 註冊\n","text",[30,1104,1101],{"__ignoreMap":167},[14,1106,1107,1110,1111,1114,1115,1118],{},[30,1108,1109],{},"NexusGPT.UseCase"," 不參考任何 Adapter 專案，所以在 UseCase 裡",[18,1112,1113],{},"根本寫不出"," ",[30,1116,1117],{},"new AmazonS3Client()","——編譯器會直接報錯。比起靠 Code Review 提醒「不要在業務邏輯裡呼叫 SDK」，讓專案參考關係擋住要可靠得多。",[14,1120,1121,1122,1125,1126,142],{},"只有 ",[30,1123,1124],{},"WebApplication"," 這個最外層的專案會參考所有其他專案，負責把 Adapter 組裝起來，這個位置也稱為 ",[18,1127,1128],{},"Composition Root",[10,1130,1131],{"id":1131},"帶來的好處",[14,1133,1134,1137],{},[18,1135,1136],{},"測試變簡單","：UseCase 只依賴介面，單元測試直接給假的實作，不需要資料庫、S3、OpenAI：",[162,1139,1141],{"className":164,"code":1140,"language":166,"meta":167,"style":167},"[Fact]\npublic async Task HandleAsync_圖片訊息_訊息會帶有儲存後的圖片網址()\n{\n    var storage = Substitute.For\u003CIImageStorage>();\n    storage.SaveAsync(default!, default!, default).ReturnsForAnyArgs(\"https:\u002F\u002Fimg\u002F1.png\");\n    var repository = new InMemoryChatRoomRepository(ChatRoom.Create(\"新對話\"));\n\n    var sut = new AddImageMessageService(storage, repository);\n    await sut.HandleAsync(repository.Id, \"一隻貓\", Stream.Null, CancellationToken.None);\n\n    repository.Saved.Messages.Last().ImageUrl.Should().Be(\"https:\u002F\u002Fimg\u002F1.png\");\n}\n",[30,1142,1143,1154,1168,1172,1195,1229,1256,1260,1276,1296,1300,1326],{"__ignoreMap":167},[171,1144,1145,1148,1151],{"class":173,"line":174},[171,1146,1147],{"class":198},"[",[171,1149,1150],{"class":191},"Fact",[171,1152,1153],{"class":198},"]\n",[171,1155,1156,1158,1160,1162,1165],{"class":173,"line":181},[171,1157,185],{"class":184},[171,1159,393],{"class":184},[171,1161,396],{"class":191},[171,1163,1164],{"class":191}," HandleAsync_圖片訊息_訊息會帶有儲存後的圖片網址",[171,1166,1167],{"class":198},"()\n",[171,1169,1170],{"class":173,"line":195},[171,1171,199],{"class":198},[171,1173,1174,1177,1180,1182,1185,1188,1190,1192],{"class":173,"line":202},[171,1175,1176],{"class":184},"    var",[171,1178,1179],{"class":191}," storage",[171,1181,447],{"class":184},[171,1183,1184],{"class":198}," Substitute.",[171,1186,1187],{"class":191},"For",[171,1189,208],{"class":198},[171,1191,111],{"class":191},[171,1193,1194],{"class":198},">();\n",[171,1196,1197,1200,1202,1204,1207,1209,1211,1213,1216,1219,1222,1224,1227],{"class":173,"line":248},[171,1198,1199],{"class":198},"    storage.",[171,1201,217],{"class":191},[171,1203,220],{"class":198},[171,1205,1206],{"class":184},"default!",[171,1208,228],{"class":198},[171,1210,1206],{"class":184},[171,1212,228],{"class":198},[171,1214,1215],{"class":184},"default",[171,1217,1218],{"class":198},").",[171,1220,1221],{"class":191},"ReturnsForAnyArgs",[171,1223,220],{"class":198},[171,1225,1226],{"class":481},"\"https:\u002F\u002Fimg\u002F1.png\"",[171,1228,245],{"class":198},[171,1230,1231,1233,1235,1237,1240,1243,1246,1248,1250,1253],{"class":173,"line":254},[171,1232,1176],{"class":184},[171,1234,342],{"class":191},[171,1236,447],{"class":184},[171,1238,1239],{"class":184}," new",[171,1241,1242],{"class":191}," InMemoryChatRoomRepository",[171,1244,1245],{"class":198},"(ChatRoom.",[171,1247,713],{"class":191},[171,1249,220],{"class":198},[171,1251,1252],{"class":481},"\"新對話\"",[171,1254,1255],{"class":198},"));\n",[171,1257,1258],{"class":173,"line":261},[171,1259,258],{"emptyLinePlaceholder":257},[171,1261,1262,1264,1267,1269,1271,1273],{"class":173,"line":267},[171,1263,1176],{"class":184},[171,1265,1266],{"class":191}," sut",[171,1268,447],{"class":184},[171,1270,1239],{"class":184},[171,1272,327],{"class":191},[171,1274,1275],{"class":198},"(storage, repository);\n",[171,1277,1278,1281,1284,1287,1290,1293],{"class":173,"line":278},[171,1279,1280],{"class":184},"    await",[171,1282,1283],{"class":198}," sut.",[171,1285,1286],{"class":191},"HandleAsync",[171,1288,1289],{"class":198},"(repository.Id, ",[171,1291,1292],{"class":481},"\"一隻貓\"",[171,1294,1295],{"class":198},", Stream.Null, CancellationToken.None);\n",[171,1297,1298],{"class":173,"line":283},[171,1299,258],{"emptyLinePlaceholder":257},[171,1301,1302,1305,1308,1311,1314,1317,1320,1322,1324],{"class":173,"line":301},[171,1303,1304],{"class":198},"    repository.Saved.Messages.",[171,1306,1307],{"class":191},"Last",[171,1309,1310],{"class":198},"().ImageUrl.",[171,1312,1313],{"class":191},"Should",[171,1315,1316],{"class":198},"().",[171,1318,1319],{"class":191},"Be",[171,1321,220],{"class":198},[171,1323,1226],{"class":481},[171,1325,245],{"class":198},[171,1327,1328],{"class":173,"line":316},[171,1329,251],{"class":198},[14,1331,1332,1335,1336,1338],{},[18,1333,1334],{},"技術決策可以延後、可以反悔","：一開始用 SQL Server，之後想支援 DynamoDB 或 MongoDB，只要為 ",[30,1337,339],{}," 多寫一個 Adapter。",[14,1340,1341,1344],{},[18,1342,1343],{},"業務邏輯集中","：商業規則都在核心裡，閱讀程式碼時不會被 SDK 的細節淹沒。",[10,1346,1347],{"id":1347},"什麼時候不需要",[14,1349,1350,1351,1354,1355,1358],{},"六角形架構會讓專案數量和介面數量變多。如果系統只是單純的 CRUD、沒有什麼商業規則、外部依賴也不會更換，一個專案加上清楚的資料夾分層就夠了。它最有價值的地方，是",[18,1352,1353],{},"商業規則複雜","、或",[18,1356,1357],{},"外部依賴明確需要抽換","（不同環境、不同客戶、不同雲端）的系統。",[10,1360,1362],{"id":1361},"使用情境內部-gpt-平台的開源版本","使用情境：內部 GPT 平台的開源版本",[14,1364,1365],{},"公司內部 GPT 平台的核心程式碼整理成開源專案時，就是以六角形架構切分：",[23,1367,1368,1371,1374],{},[26,1369,1370],{},"聊天室、訊息、圖片訊息的商業規則放在 Domain 與 UseCase",[26,1372,1373],{},"資料庫存取、圖片儲存、OpenAI 呼叫都是 Driven Adapter",[26,1375,1376],{},"圖片儲存提供 Local 與 S3 兩種 Adapter：本機開發掛載一個資料夾就能跑，正式環境設定 S3 的環境變數即可，Docker 映像完全相同",[10,1378,1379],{"id":1379},"一句話總結",[14,1381,1382],{},"六角形架構把系統分成「只有業務的核心」和「接外部技術的 Adapter」，兩者之間用核心定義的 Port（介面）隔開，所有依賴都指向核心；再用專案參考關係讓編譯器守住這個方向，就能做到換資料庫、換儲存、換外部服務都不必修改業務程式，單元測試也不再需要任何外部環境。",[1384,1385,1386],"style",{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}",{"title":167,"searchDepth":181,"depth":181,"links":1388},[1389,1390,1391,1392,1393,1394,1395,1396,1397],{"id":12,"depth":181,"text":12},{"id":51,"depth":181,"text":52},{"id":134,"depth":181,"text":135},{"id":548,"depth":181,"text":549},{"id":1093,"depth":181,"text":1093},{"id":1131,"depth":181,"text":1131},{"id":1347,"depth":181,"text":1347},{"id":1361,"depth":181,"text":1362},{"id":1379,"depth":181,"text":1379},"2023-12-19",null,"md",{},"\u002Farticles\u002Fhexagonal-architecture-dotnet",{"title":5,"description":167},"articles\u002Fhexagonal-architecture-dotnet",[1406,1407,1408,1409,1410],"Hexagonal Architecture","Clean Architecture",".NET","架構設計","DDD","qkJBhBszATTq8XMm0hwBG1uwmtISRljRLSi5hvNEGQc",1792959150441]