DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Frameworks

A framework is a collection of code that is leveraged in the development process by providing ready-made components. Through the use of frameworks, architectural patterns and structures are created, which help speed up the development process. This Zone contains helpful resources for developers to learn about and further explore popular frameworks such as the Spring framework, Drupal, Angular, Eclipse, and more.

icon
Latest Premium Content
Trend Report
Low-Code Development
Low-Code Development
Refcard #288
Getting Started With Low-Code Development
Getting Started With Low-Code Development
Refcard #348
E-Commerce Development Essentials
E-Commerce Development Essentials

DZone's Featured Frameworks Resources

Pure Headless vs Hybrid Headless CMS: A Practical Decision Framework

Pure Headless vs Hybrid Headless CMS: A Practical Decision Framework

By Alex Vakulov DZone Core CORE
Headless CMS architecture solved a real development problem. It separated content from presentation, gave frontend teams control over frameworks and deployment, and made structured content available to websites, apps, and other channels through APIs. The friction often appears later, when content operations become more complex. Routine publishing changes can still depend on engineering, especially when editors need more control over layout, preview, or page composition. That gap is why some teams consider a different architectural pattern: hybrid headless CMS. It keeps the structured, API-based approach of headless while adding a visual authoring layer to assemble approved components. The Authoring Problem Behind Pure Headless In a pure headless setup, the CMS manages structured content while the frontend controls how that content is rendered. For developers, that separation is valuable. Teams can use React, Vue, Svelte, native applications, or another presentation layer without tying the frontend directly to the CMS. The tradeoff becomes more visible when presentation changes frequently. A CMS may contain a hero title, image, CTA, and product description, but the frontend still determines how those elements become a page. Supporting visual preview, flexible layouts, and reusable page composition can therefore require additional engineering around preview APIs, component mapping, draft rendering, routing, deployment, etc. None of this is inherently a weakness in headless architecture. It is implementation work that teams need to account for. For applications with stable layouts and highly structured content, the model can work extremely well. For enterprises running many sites, markets, and campaigns, the amount of presentation-related work can become an operational bottleneck. When Content Work Becomes Engineering Work The clearest signal is the backlog. Consider a marketing team launching ten regional campaign pages. The content already exists, and no new application behavior is required. But several regions need a different component order, one needs an additional promotional block, and another needs a temporary landing page. In a tightly controlled pure headless implementation, those requests may still require developers to modify templates or component configuration. The workflow can become: Content request → development ticket → code change → review → build → deployment → editor validation That process makes sense when the requested change affects application behavior. It becomes expensive when the request is simply to rearrange approved components. Preview creates a similar issue. Headless systems can support preview, but developers often have to connect draft content with the rendering application so editors can see the actual result before publication. The CMS provides structured data. The frontend provides the presentation context. The distinction matters because the application still owns rendering, routing, accessibility, performance, and browser behavior. MDN provides useful background on the separation between server-side systems and client-facing application behavior. What Hybrid Headless Changes Hybrid headless keeps the API-based content model but adds visual composition capabilities for editors. Instead of letting editors create arbitrary frontend code, developers define the available building blocks. A content team can then assemble approved components through the CMS while the frontend remains responsible for how those components render. For example, developers might provide: HeroProduct gridCustomer quotePricing blockCTAFAQ Editors can change the order or selection of those components without changing the underlying application. The key difference is where composition happens. capabilitypure headlesshybrid headless Structured content Yes Yes API delivery Yes Yes Framework freedom Yes Yes Page composition Usually implemented in frontend logic Can be exposed through CMS authoring tools Visual preview Possible, often requires integration Commonly integrated into the authoring workflow Editor-controlled layouts Depends on implementation Typically a core capability Component governance Application specific Central to the model Definitions vary between CMS vendors, so engineering teams should evaluate the architecture rather than the label. A platform described as hybrid should still expose a clean delivery API that applications can consume independently. If the frontend becomes dependent on proprietary page rendering behavior, teams may reintroduce some of the coupling they were trying to remove. Developers Still Own the Architecture Hybrid headless changes who handles routine page composition, but developers still control the technical boundaries. They define components, validation, accessibility, performance, and application behavior. They also own the delivery contract between the CMS and frontend, including the security implications of new integrations and features. For teams adopting AI-powered capabilities, resources with AI security explained in practical terms can help clarify some of those risks. Overall, that means the architecture still depends on disciplined component and API design. Components that are too rigid send editors back to development tickets. Too many overlapping components create governance problems. The goal is simple: editors control approved composition, while developers retain control over how the application works. When Pure Headless Is Still the Better Fit Pure headless remains a strong choice when presentation is primarily application logic. A product dashboard is a good example. Developers may control nearly every screen because layout, state, permissions, and application behavior are closely connected. Pure headless also fits well when content changes are mostly structured data changes rather than page composition. Typical signals include: A small number of highly custom applicationsStable page structuresLimited need for editor-controlled layoutsContent reused heavily across channelsStrong frontend engineering capacityPresentation decisions that should remain in code In these environments, adding visual composition may introduce complexity without solving a real problem. When Hybrid Headless Becomes More Practical Hybrid approaches become more attractive when content operations generate repeated frontend work. Common signals include: Many sites, markets, or brands using the same component libraryFrequent campaign pagesEditors who need reliable visual previewRegular requests to rearrange approved page componentsEngineering queues filled with presentation changes that contain little new logicTeams that need stronger separation between component development and page assembly A useful test is to pull the previous quarter's engineering backlog and count how many tickets were created primarily to move an existing content block, change a layout, build a campaign page from existing components, or make another presentation change that required no new application behavior. Then look at who filed those tickets. If the same content or marketing teams repeatedly depend on developers for short-lived campaign changes, the organization may need more authoring autonomy rather than more frontend capacity. The Tradeoffs Hybrid Headless Does Not Remove Visual composition shifts work rather than eliminating it. Component governance becomes more important because shared components now act as an interface between engineering and content teams. Someone needs to own versioning, accessibility, documentation, budgets, and backward compatibility. Preview also needs production-quality engineering. A visual editor is useful only when what the editor sees accurately reflects what users will receive. Teams also need to decide how much flexibility to expose. Unlimited layout freedom can create inconsistent pages and undermine a design system. Too little flexibility recreates the ticket backlog the architecture was meant to reduce. The goal is controlled composition. Developers create safe building blocks. Editors assemble them within defined constraints. Evaluate the Workflow, Not the Label The architecture decision should start with the actual publishing workflow. Map who creates content, who changes layouts, who builds components, how preview works, what triggers a deployment, and which requests currently require engineering involvement. Then examine the CMS boundary. Can content be consumed independently through APIs? Can developers control component behavior? Can editors perform routine composition without changing application code? Can teams preview changes accurately? Can the architecture support additional channels without rebuilding the content model? Pure headless and hybrid headless preserve the same core idea: separating content from presentation. The practical difference is how much controlled presentation capability the platform gives back to content teams. For developers, the goal is to keep engineering focused on work that actually requires engineering. If developers are building components, integrations, and application behavior, the architecture is doing useful work. If they are repeatedly moving existing blocks around landing pages, the boundary probably needs another look. More
Understanding RabbitMQ Exchange Types in Spring Boot

Understanding RabbitMQ Exchange Types in Spring Boot

By Gunter Rotsaert DZone Core CORE
In this blog, you will take a closer look at the different exchange types that can be used in RabbitMQ. All are demonstrated by means of examples in a Spring Boot application. Enjoy! Introduction In the previous blog, you learned the basic concepts of RabbitMQ and how to use it in a Spring Boot application. However, you only scratched the surface of it, so now it is time to dig a bit deeper into the different exchange types. If you are not yet familiar with the basic concepts, it is advised to read the previous blog. The official RabbitMQ documentation also provides detailed information that is worth reading. Sources used in this blog can be found on GitHub. Prerequisites Prerequisites for reading this blog are: Basic knowledge of Java;Basic knowledge of Spring Boot;Basic knowledge of Docker Compose;Basic knowledge of RabbitMQ. Topics The code can be found in the topics module. In the previous blog, you created two consumers A and B. Consumer A was bound to Queue A with routing key event.general.*. Consumer B was bound to Queue B with routing keys event.general.* and event.specific.*. The asterisk (*) wildcard was used and is a substitute for exactly one word. In the examples, the routing keys event.general.message and event.specific.message were used. You can also use the hash (#) wildcard, and this is a substitute for zero or more words. This is visualized in the figure below. In the RabbitMqConfig, you declare queue C and bind it to the TopicExchange with routing key event.general.#. Java public static final String QUEUE_CONSUMER_C = "consumer-c.queue"; public static final String ROUTING_KEY_NESTED_GENERAL_MESSAGE = "event.general.#"; @Bean Binding bindingConsumerBSpecific(Queue queueConsumerB, TopicExchange exchange) { return BindingBuilder.bind(queueConsumerB).to(exchange).with(ROUTING_KEY_SPECIFIC_MESSAGE); } @Bean public Queue queueConsumerC() { return new Queue(QUEUE_CONSUMER_C, false); } @Bean Binding bindingConsumerCNestedGeneral(Queue queueConsumerC, TopicExchange exchange) { return BindingBuilder.bind(queueConsumerC).to(exchange).with(ROUTING_KEY_NESTED_GENERAL_MESSAGE); } In the MessageController, you create an endpoint for sending a message with routing key event.general.message.nested. This routing key will not match the bindings of consumers A and B. Java @RequestMapping( method = RequestMethod.POST, value = "send-nested-general" ) public ResponseEntity<Void> sendNestedGeneralMessage(@RequestBody String message) { messageService.sendMessage("event.general.message.nested", message); return new ResponseEntity<>(HttpStatus.CREATED); } The ReceiverC listens to messages received in queue C and prints a message. Java @Component public class ReceiverC { @RabbitListener(queues = RabbitMqConfig.QUEUE_CONSUMER_C) public void receiveMessage(String message) { System.out.println("Queue Consumer C received <" + message + ">"); } } Start the application from within the topics module. Shell mvn spring-boot:run First, post a general message; this should be received by all consumers. Shell curl -X POST http://localhost:8080/send-general \ -H "Content-Type: text/plain" \ -d "This is a general message" In the application console log, you notice that all consumers receive the message. Plain Text Queue Consumer B received <This is a general message> Queue Consumer A received <This is a general message> Queue Consumer C received <This is a general message> Now, post a nested general message, which should be received only by consumer C. Shell curl -X POST http://localhost:8080/send-nested-general \ -H "Content-Type: text/plain" \ -d "This is a nested general message" In the application console log, you notice that the message is only received by consumer C. Plain Text Queue Consumer C received <This is a nested general message> Work Queues The code can be found in the work module. With work queues, you can publish a message and dispatch it to a pool of consumers. One of the consumers will pick up the message and start processing it. This is especially useful for dispatching long-running tasks. You use the default direct exchange in this case, and the queue name is used as the routing key. No need to use a custom exchange. This is visualized in the figure below. The RabbitMqConfig is quite small; you only define the queue. Java @Configuration public class RabbitMqConfig { public static final String QUEUE_TASK = "task.queue"; @Bean public Queue queueTask() { return new Queue(QUEUE_TASK, false); } } When sending a message via an endpoint, you use the queue name as the routing key. Java @RequestMapping( method = RequestMethod.POST, value = "send-work" ) public ResponseEntity<Void> sendWorkMessage(@RequestBody String message) { messageService.sendMessage(RabbitMqConfig.QUEUE_TASK, message); return new ResponseEntity<>(HttpStatus.CREATED); } Every consumer listens to the queue. Java @Component public class ReceiverA { @RabbitListener(queues = RabbitMqConfig.QUEUE_TASK) public void receiveMessage(String message) { System.out.println("Task picked up by Consumer A <" + message + ">"); } } @Component public class ReceiverB { @RabbitListener(queues = RabbitMqConfig.QUEUE_TASK) public void receiveMessage(String message) { System.out.println("Task picked up by Consumer B <" + message + ">"); } } @Component public class ReceiverC { @RabbitListener(queues = RabbitMqConfig.QUEUE_TASK) public void receiveMessage(String message) { System.out.println("Task picked up by Consumer C <" + message + ">"); } } Start the application from within the work module. Shell mvn spring-boot:run Send a message to the queue. Shell curl -X POST http://localhost:8080/send-work \ -H "Content-Type: text/plain" \ -d "This is a work message" The message is processed by one consumer. Plain Text Task picked up by Consumer A <This is a work message> Fanout The code can be found in the fanout module. With fanout, you want to broadcast messages to all queues. You send messages to the exchange, but there is no need to specify a routing key. You can also ensure that temporary queues are used. When temporary queues are used, the queue name will be generated. In the RabbitMqConfig, you define a FanoutExchange. The queues are defined as an AnonymousQueue. This creates a non-durable, exclusive, auto-delete queue with a generated name. You bind the queues to the exchange. Java @Configuration public class RabbitMqConfig { public static final String FANOUT_EXCHANGE_NAME = "fanout.exchange"; @Bean FanoutExchange fanoutExchange() { return new FanoutExchange(FANOUT_EXCHANGE_NAME); } @Bean public Queue queueConsumerA() { return new AnonymousQueue(); } @Bean Binding bindingConsumerA(Queue queueConsumerA, FanoutExchange exchange) { return BindingBuilder.bind(queueConsumerA).to(exchange); } @Bean public Queue queueConsumerB() { return new AnonymousQueue(); } @Bean Binding bindingConsumerBGeneral(Queue queueConsumerB, FanoutExchange exchange) { return BindingBuilder.bind(queueConsumerB).to(exchange); } @Bean Binding bindingConsumerBSpecific(Queue queueConsumerB, FanoutExchange exchange) { return BindingBuilder.bind(queueConsumerB).to(exchange); } } In order to send messages, you only need to send them to the exchange. This can be seen in the MessageService. Java public void sendMessage(String message) { rabbitTemplate.convertAndSend(RabbitMqConfig.FANOUT_EXCHANGE_NAME, "", message); } On the receiving side, you listen to the generated queue name (thus not a specific one in this case). Java @Component public class ReceiverA { @RabbitListener(queues = "#{queueConsumerA.name}") public void receiveMessage(String message) { System.out.println("Queue Consumer A received <" + message + ">"); } } @Component public class ReceiverB { @RabbitListener(queues = "#{queueConsumerB.name}") public void receiveMessage(String message) { System.out.println("Queue Consumer B received <" + message + ">"); } } Start the application from within the fanout module. Shell mvn spring-boot:run Send a message to the queue. Shell curl -X POST http://localhost:8080/send-to-all \ -H "Content-Type: text/plain" \ -d "This is a fanout message" In the application console log, you notice that the message is consumed by all queues. Plain Text Queue Consumer B received <This is a fanout message> Queue Consumer A received <This is a fanout message> RPC The code can be found in the RPC module. Remote Procedure Call (RPC) can be used when you need to execute a function on a remote application and wait for the result. The event is sent to the queue and is processed by Consumer A. The result is sent to a queue in the replyTo field of the request. The publisher waits for data to be returned on this callback queue. When the message appears, it checks the correlationId. If it matches the value of the request, the response is returned to the publisher. All of this is done automatically by the RabbitTemplate. In the RabbitMqConfig, a DirectExchange is used. With a DirectExchange, you match exactly on events; you cannot use wildcards here, just like a TopicExchange. Java @Configuration public class RabbitMqConfig { public static final String QUEUE_CONSUMER_A = "consumer-a.queue"; public static final String DIRECT_EXCHANGE_NAME = "events.exchange"; public static final String ROUTING_KEY_RPC_MESSAGE = "event.rpc"; @Bean DirectExchange eventsExchange() { return new DirectExchange(DIRECT_EXCHANGE_NAME); } @Bean public Queue queueConsumerA() { return new Queue(QUEUE_CONSUMER_A, false); } @Bean Binding bindingConsumerA(Queue queueConsumerA, DirectExchange exchange) { return BindingBuilder.bind(queueConsumerA).to(exchange).with(ROUTING_KEY_RPC_MESSAGE); } } The MessageController contains an endpoint for sending the event. Java @RequestMapping( method = RequestMethod.POST, value = "send-rpc" ) public ResponseEntity<Void> sendRpcMessage(@RequestBody String message) { messageService.sendMessage(message); return new ResponseEntity<>(HttpStatus.CREATED); } In the MessageService, you use convertSendAndReceive and process the response. Java public void sendMessage(String message) { Object response = rabbitTemplate.convertSendAndReceive(RabbitMqConfig.DIRECT_EXCHANGE_NAME, ROUTING_KEY_RPC_MESSAGE, message); if (response != null) { System.out.println("Sender received response: " + response); } else { System.out.println("No response received"); } } In the receiver, you receive the message and send a response. Do note that some additional processing is added in order to trigger a timeout. More on that in a moment. Java @Component public class ReceiverA { @RabbitListener(queues = RabbitMqConfig.QUEUE_CONSUMER_A) public String receiveMessage(String message) { System.out.println("Queue Consumer A received <" + message + ">"); if (message.equals("This is an rpc message")) { return "success"; } else if (message.equals("This is a timeout message")) { try { Thread.sleep(10000); } catch (InterruptedException e) { throw new RuntimeException(e); } return "success"; } else { return "failure"; } } } Start the application from within the rpc module. Shell mvn spring-boot:run Send a message to the queue. Shell curl -X POST http://localhost:8080/send-rpc \ -H "Content-Type: text/plain" \ -d "This is an rpc message" In the application console log, you notice that the message is consumed by consumer A, and that a successful response is received by the publisher. Plain Text Queue Consumer A received <This is an rpc message> Sender received response: success But what if it takes too long to process the message? In real life, the remote application can be unreachable for one reason or another. Send a timeout message. Shell curl -X POST http://localhost:8080/send-rpc \ -H "Content-Type: text/plain" \ -d "This is a timeout message" In the MessageService, the response will return null, and a timeout exception is raised. Plain Text Queue Consumer A received <This is a timeout message> No response received 2026-04-25T14:50:16.785+02:00 WARN 482297 --- [MySpringRabbitMqPlanet] [pool-2-thread-8] o.s.amqp.rabbit.core.RabbitTemplate : Reply received after timeout for 2 2026-04-25T14:50:16.785+02:00 WARN 482297 --- [MySpringRabbitMqPlanet] [pool-2-thread-8] s.a.r.l.ConditionalRejectingErrorHandler : Execution of Rabbit message listener failed. org.springframework.amqp.rabbit.support.ListenerExecutionFailedException: Listener threw exception at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.wrapToListenerExecutionFailedExceptionIfNeeded(AbstractMessageListenerContainer.java:1795) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.doInvokeListener(AbstractMessageListenerContainer.java:1687) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.actualInvokeListener(AbstractMessageListenerContainer.java:1612) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.invokeListener(AbstractMessageListenerContainer.java:1599) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.doExecuteListener(AbstractMessageListenerContainer.java:1590) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.executeListenerAndHandleException(AbstractMessageListenerContainer.java:1539) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.executeListener(AbstractMessageListenerContainer.java:1520) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.DirectMessageListenerContainer$SimpleConsumer.callExecuteListener(DirectMessageListenerContainer.java:1206) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.DirectMessageListenerContainer$SimpleConsumer.handleDelivery(DirectMessageListenerContainer.java:1163) ~[spring-rabbit-4.0.2.jar:4.0.2] at com.rabbitmq.client.impl.ConsumerDispatcher$5.run(ConsumerDispatcher.java:149) ~[amqp-client-5.27.1.jar:5.27.1] at com.rabbitmq.client.impl.ConsumerWorkService$WorkPoolRunnable.run(ConsumerWorkService.java:111) ~[amqp-client-5.27.1.jar:5.27.1] at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1090) ~[na:na] at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:614) ~[na:na] at java.base/java.lang.Thread.run(Thread.java:1474) ~[na:na] Caused by: org.springframework.amqp.AmqpRejectAndDontRequeueException: Reply received after timeout at org.springframework.amqp.rabbit.core.RabbitTemplate.onMessage(RabbitTemplate.java:2721) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.DirectReplyToMessageListenerContainer.lambda$setMessageListener$0(DirectReplyToMessageListenerContainer.java:93) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.doInvokeListener(AbstractMessageListenerContainer.java:1683) ~[spring-rabbit-4.0.2.jar:4.0.2] ... 12 common frames omitted 2026-04-25T14:50:16.790+02:00 ERROR 482297 --- [MySpringRabbitMqPlanet] [pool-2-thread-8] .l.DirectReplyToMessageListenerContainer : Failed to invoke listener org.springframework.amqp.rabbit.support.ListenerExecutionFailedException: Listener threw exception at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.wrapToListenerExecutionFailedExceptionIfNeeded(AbstractMessageListenerContainer.java:1795) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.doInvokeListener(AbstractMessageListenerContainer.java:1687) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.actualInvokeListener(AbstractMessageListenerContainer.java:1612) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.invokeListener(AbstractMessageListenerContainer.java:1599) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.doExecuteListener(AbstractMessageListenerContainer.java:1590) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.executeListenerAndHandleException(AbstractMessageListenerContainer.java:1539) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.executeListener(AbstractMessageListenerContainer.java:1520) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.DirectMessageListenerContainer$SimpleConsumer.callExecuteListener(DirectMessageListenerContainer.java:1206) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.DirectMessageListenerContainer$SimpleConsumer.handleDelivery(DirectMessageListenerContainer.java:1163) ~[spring-rabbit-4.0.2.jar:4.0.2] at com.rabbitmq.client.impl.ConsumerDispatcher$5.run(ConsumerDispatcher.java:149) ~[amqp-client-5.27.1.jar:5.27.1] at com.rabbitmq.client.impl.ConsumerWorkService$WorkPoolRunnable.run(ConsumerWorkService.java:111) ~[amqp-client-5.27.1.jar:5.27.1] at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1090) ~[na:na] at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:614) ~[na:na] at java.base/java.lang.Thread.run(Thread.java:1474) ~[na:na] Caused by: org.springframework.amqp.AmqpRejectAndDontRequeueException: Reply received after timeout at org.springframework.amqp.rabbit.core.RabbitTemplate.onMessage(RabbitTemplate.java:2721) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.DirectReplyToMessageListenerContainer.lambda$setMessageListener$0(DirectReplyToMessageListenerContainer.java:93) ~[spring-rabbit-4.0.2.jar:4.0.2] at org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer.doInvokeListener(AbstractMessageListenerContainer.java:1683) ~[spring-rabbit-4.0.2.jar:4.0.2] ... 12 common frames omitted How to solve this? In this case, you are better off using the AsyncRabbitTemplate. This template is not automatically autowired, so you have to define it as a bean. Let's do so in the RabbitMqConfig. Java @Bean public AsyncRabbitTemplate asyncRabbitTemplate(RabbitTemplate rabbitTemplate) { return new AsyncRabbitTemplate(rabbitTemplate); } In the MessageController, you define an endpoint to trigger the async template. Java @RequestMapping( method = RequestMethod.POST, value = "send-async" ) public ResponseEntity<Void> sendAsyncMessage(@RequestBody String message) { messageService.sendAsyncMessage(message); return new ResponseEntity<>(HttpStatus.CREATED); } In the MessageService, you autowire the AsyncRabbitTemplate. And because it is an async call, you catch the response by means of a CompletableFuture. Java public void sendAsyncMessage(String message) { CompletableFuture<Object> future = asyncRabbitTemplate.convertSendAndReceive(RabbitMqConfig.DIRECT_EXCHANGE_NAME, ROUTING_KEY_RPC_MESSAGE, message); future.thenAccept(response -> { if (response != null) { System.out.println("Sender received response: " + response); } else { System.out.println("No response received"); } }); } Start the application from within the rpc module. Shell mvn spring-boot:run Send a message to the queue. Shell curl -X POST http://localhost:8080/send-async \ -H "Content-Type: text/plain" \ -d "This is a timeout message" In the application log, you see the same result: the response is null, but no timeout exception anymore. Conclusion In this post, you learned different exchange types. Each serves its own use case. It is up to you to choose the right pattern for your use case. More
Containerizing Spark and Lakehouse Development with Docker
Containerizing Spark and Lakehouse Development with Docker
By Aniket Abhishek Soni
Demystifying Thread Hopping With Swift 6.2
Demystifying Thread Hopping With Swift 6.2
By Nikita Vasilev
Cutting AI Token Costs With MgntUtils Stack Trace Filtering
Cutting AI Token Costs With MgntUtils Stack Trace Filtering
By Michael Gantman
Stop Hand-Rolling Chat UIs: Streaming LLM Tokens Into React Native Without the Jank
Stop Hand-Rolling Chat UIs: Streaming LLM Tokens Into React Native Without the Jank

A chat screen looks like a weekend project: a list of bubbles and a text input pinned to the bottom. In React Native, it is one of the hardest things to ship well, because it sits on top of the two most hostile surfaces in mobile development: the software keyboard and a scrolling list that changes size while you're looking at it. We're putting LLMs into everything now, and there is still no good drop-in chat view for React Native. You glue together an aging library with strong opinions, or you hand-roll it. I hand-rolled it. Then I made the LLM stream its replies token by token, and the whole thing fell apart in a way that took a week to understand. This is the story of that break, and the fix, which arrived with suspicious good timing as a library release three months ago. The App I work on an app built around an LLM chat: characters that remember you and reply as an open-ended story unfolds. The messages between the reader and the characters are rendered in a chat-like view: an inverted list, the newest message at the bottom, and a composer pinned above the keyboard. Standard chat anatomy. The twist that makes it hard: the character replies are generated by an LLM, and they stream. Tokens arrive in bursts, a few every hundred milliseconds, with a full reply landing over two or three seconds. Each batch makes the last bubble taller. The list isn't just appending a finished message. It's growing on every frame, while the user might be typing, scrolling, or dismissing the keyboard. That single fact is what turns "I'll just use a FlatList" into weeks of work. Why There's Nothing Good to Reach For The first thing I did was look for a library. The honest state of the art: react-native-gifted-chat is the default answer and it's showing its age. It's opinionated about your data shape, its rendering, and its layout, and fighting those opinions costs more than writing your own.Most "chat UI" packages are really just a styled `FlatList` plus a text input. They solve the easy half and hand you the two genuinely hard problems: keyboard choreography and a live-resizing list.The keyboard utilities that _do_ exist (`KeyboardAvoidingView` and friends) were built for forms, not for an inverted list whose last row is growing while the keyboard animates. So I wrote my own keyboard-and-scroll layer. It was close to 500 lines of KeyboardAvoidingView overrides, manual scrollToOffset calls, listeners on keyboard show/hide events, and offset math to keep the composer glued to the keyboard. It worked, demos looked clean, and I shipped it. The Break: Streaming Meets the Keyboard The bug reports were all variations on "the chat is jumpy." No crashes, just jank. I couldn't reproduce it at first because each of the two features behaved perfectly on its own. The keyboard animation was smooth. The streaming was smooth. The problem only showed up at their intersection. That's the kind of bug that costs a week, because nothing is actually broken. Two correct things are simply disagreeing. Here's what was actually happening. While a character reply streams in: Every batch of tokens makes the last bubble taller.On an inverted list, growing the bottom row shifts the content offset.React Native re-runs the layout to absorb the new height.If the keyboard is open, or worse, mid-animation, my keyboard layer is _also_ adjusting offsets at the same time. Two systems are writing to the scroll position on the same frames. The result: the content jumps, the composer twitches, and if the user has scrolled up to re-read an earlier message, the stream yanks them around. Layout thrash. A steady 60fps collapsed into the low teens precisely when the app is supposed to feel most alive, and on a mid-range Android phone, it was worse. TypeScript // The naive streaming append: looks innocent, thrashes layout. // Every chunk triggers a re-measure of the growing bubble, // which fights whatever the keyboard handler is doing this frame. for await (const chunk of stream) { setMessages((prev) => { const next = [...prev]; next[0] = { ...next[0], text: next[0].text + chunk }; // index 0 = newest, inverted list return next; }); } The streaming itself has its own sharp edges, and they compound the layout problem. Two worth calling out before the fix: React Native's fetch can't stream a response body. There's no response.body.getReader() in stock RN. You reach for an SSE polyfill like react-native-sse or if you're on Expo like me, the streaming-capable fetch from expo/fetch. Pick deliberately. This is the single most common thing people get wrong on day one. TypeScript import { fetch } from "expo/fetch"; const res = await fetch(url, { method: "POST", body, signal: controller.signal, }); const reader = res.body.getReader(); const decoder = new TextDecoder(); // ...read loop, parse SSE frames, dispatch tokens Partial markdown will bite you. Tokens arrive mid-syntax. At some frame, your buffer is literally The dragon turned and **stared with the bold marker opened and not yet closed. A naive markdown renderer will either render the asterisks as literal text or flip half the conversation bold. You need a renderer that tolerates unterminated syntax, or you sanitize the buffer before each render. Cancellation has to be real. The user closes the chat, switches characters, or fires off a new message mid-reply. You need an AbortController whose signal actually reaches the fetch. Skip it and you're billed for tokens nobody will read, streamed into a view that already unmounted. The Fix I was about to rewrite my keyboard layer for the fourth time when react-native-keyboard-controller shipped KeyboardChatScrollView in v1.21.0, on March 16, 2026. It is, as far as I can tell, the first component built specifically for the chat-plus-keyboard problem rather than the form-plus-keyboard one, and it happens to solve the streaming case directly. The piece that matters for an LLM app is built on a ClippingScrollView that provides cross-platform contentInset behavior by extending the scrollable geometry rather than recomputing the layout. That one design choice is why the thrash disappears. The keyboard no longer fights the list because absorbing keyboard height is no longer a layout operation. The props read like a tour of every chat app you've used: keyboardLiftBehavior picks how the content reacts to the keyboard. "always" keeps the latest messages visible no matter where you've scrolled (Telegram, WhatsApp). "whenAtEnd" lifts only when you're already at the bottom, and leaves you alone if you've scrolled up to read history (ChatGPT). "persistent" lifts when the keyboard opens and, unlike the rest, stays put when it closes instead of snapping back down (Claude). "never" lets the keyboard cover the content and moves nothing (Perplexity).blankSpace reserves room for an incoming response while absorbing keyboard height. This is the direct antidote to streaming jank. Instead of the list growing reactively frame by frame and fighting the keyboard, you reserve the space up front and let the tokens fill it.extraContentPadding handles a composer that grows as the user types a long message, without jumping the content.freeze locks the layout during emoji and attachment-picker transitions, the other place chat UIs jump. TypeScript import { KeyboardChatScrollView } from "react-native-keyboard-controller"; <KeyboardChatScrollView keyboardLiftBehavior="persistent" // the Claude pattern: lifts on open, stays put on close blankSpace={pendingReply ? estimatedReplyHeight : 0} > {messages.map(renderBubble)} </KeyboardChatScrollView>; On paper whenAtEnd is the tidy answer for a reading-heavy app: don't move the content out from under someone studying an old exchange. I shipped persistent anyway. So many of my users live in assistant apps that Claude's settle-and-stay behavior is just what their hands expect, and familiarity beat theory. Nobody had to relearn how the chat feels. My streaming loop didn't change. What changed is that the loop is now the only thing touching layout while a reply comes in. The keyboard handler stepped out of the fight. The composer stopped twitching. The user who scrolls up to re-read an old exchange stays put while the character keeps talking below the fold. What I'd Keep, and What I'd Throw Away If I were starting Y/N's chat today, I'd delete my hand-rolled keyboard layer without ceremony and start from KeyboardChatScrollView. The custom code I'd keep is the part that was always mine to own: the streaming reader, the partial-markdown guard, and the cancellation plumbing. Those aren't keyboard problems, and no layout library will solve them for you. The general lesson applies well beyond chat. The expensive bug is almost never one broken feature. It's two correct features interacting on the same frame. My keyboard handler was right. My streaming was right. The week disappeared into the seam between them. When something janks and every part tests clean in isolation, stop testing the parts and go look at what they're both writing to. And the smaller, practical one: the chat box is never the easy part of the app. Budget for it like it's a feature, because it is one. For the first time in a while, you don't have to build all of it yourself. If you've solved the Android side of this, or made partial-markdown rendering feel good while streaming, I'd be glad to compare notes in the comments.

By Tammo Ronke
Benchmark LangGraph, Strands, OpenAI Agents, and Google ADK on the Same Agent Graph
Benchmark LangGraph, Strands, OpenAI Agents, and Google ADK on the Same Agent Graph

Agent framework debates are mostly vibes. One engineer swears LangGraph is faster, another prefers the OpenAI Agents SDK, someone wants Google ADK because it feels future-proof. The team picks one, wires the workflow into its SDK, and the choice is welded in. Changing frameworks later means tearing out the wiring for one SDK and rebuilding the workflow on another, an expensive rewrite few teams take on. This tutorial makes that decision reversible and then settles it with data. You put the agent graph in LaunchDarkly and run four frameworks (LangGraph, Strands, OpenAI Agents SDK, and Google ADK) over the same topology, with the model pinned so the framework is the only variable. A LaunchDarkly experiment ranks them on graph latency and token use, with an LLM judge guarding quality. The results table tells you which framework runs your graph fastest without degrading it. This tutorial is the sequel to Compare AI orchestrators, which ran the same workflow across frameworks but kept the topology in each framework’s code. Here, the topology, routing, models, prompts, tools, and judge all live in LaunchDarkly, and each framework supplies only two functions. The experiment results do more than set a benchmark. The flag that splits experiment traffic also routes production. When one framework wins, you don’t rewrite the app; you change the flag to serve the winner. In a single loop, LaunchDarkly does three jobs: the graph definition, the experiment split, and the runtime control that ships the winner. The workload is a research-gap analysis over a set of arXiv papers. Two readers, approach-analyzer and contradiction-detector, read the same papers in parallel and fan in to gap-synthesizer, which writes the report. Prerequisites A LaunchDarkly account with AgentControl access, and your environment’s SDK keyPython 3.11+ and uvAn ANTHROPIC_API_KEY for the pinned model. OPENAI_API_KEY and GOOGLE_API_KEY are only needed if you run the optional native-model bake-off in Step 9The companion repo: ai-orchestrators on branch tutorial/graph-experiments The Experiment Design The comparison is controlled: same graph, same model, same papers, same judge, with the framework as the only variable. Mechanically, it runs in four stages: Bootstrap. manifest.yaml creates the node configs, graph, orchestrator flag, and judge in LaunchDarkly.Route. On each request, the app evaluates the orchestrator flag to pick a framework: langgraph, strands, openai-agents, or google-adk.Run. The dispatcher runs the shared graph as a directed acyclic graph (DAG). The two readers run concurrently and fan in to the synthesizer.Measure. Each run records how long the graph took, how many tokens it used, and whether the report passed the quality judge. The shape looks like this: ┌──▶ approach-analyzer ───────┐ intake (papers) ─────┤ ├──▶ gap-synthesizer ──▶ report └──▶ contradiction-detector ──┘ Step 1: Create the Graph, Flag, and Judge Everything starts from one file, config/graph_experiment_manifest.yaml. It declares the fetch_paper tool, four node configs (intake plus the three agents, pinned to claude-sonnet-4-5), the graph, the orchestrator flag, and the judge. First, clone the companion repo and install its dependencies with uv: Shell git clone https://github.com/launchdarkly-labs/ai-orchestrators cd ai-orchestrators git checkout tutorial/graph-experiments uv sync Next, set up a LaunchDarkly project. The bootstrap doesn’t create one, so create it with the LaunchDarkly MCP server, the projects agent skill, or the UI. Name it graph-experiments to match the value in .env.example, so the defaults work without edits. When it exists, copy its key into LD_PROJECT_KEY and its production environment SDK key into LD_SDK_KEY in .env. The runners and experiment harness use that SDK key to evaluate the flag and graph. The bootstrap also reads LD_API_KEY from .env to create the resources. Copy the example file to create your .env: Shell cp .env.example .env # then set LD_PROJECT_KEY, LD_SDK_KEY, and LD_API_KEY in .env With the keys in place, run the bootstrap: Shell uv run python scripts/launchdarkly/bootstrap.py config/graph_experiment_manifest.yaml This creates all four node configs, the research-gap-graph, the orchestrator flag (created off), and the gap-quality-judge attached to the gap-synthesizer node (its synthesizer-claude variation, set to 100% sampling). The judge scores the final report against the source papers, so it can verify grounding and citations. A judge can only check based on the information it has, so we give it the papers, not only an upstream agent’s analysis. When the graph ships, it is incomplete by design. The bootstrap creates the contradiction-detector config but wires only intake to approach-analyzer to gap-synthesizer, leaving the detector out. You’ll add it in Step 5 to complete the parallel fan-in. When it finishes, the bootstrap prints a link to your new agent graph. Open it and review the topology before moving on. The graph shows a straight line from intake to approach-analyzer to gap-synthesizer, with contradiction-detector created but not yet wired in. Step 2: The Dispatcher Runs the Graph The dispatcher is the heart of the project, and it’s the same code for every framework. It reads the graph as a DAG, runs the entry nodes concurrently, hands every node the papers as ground truth, and connects the readers at the fan-in node. The only framework-specific pieces are build_agent and invoke, which are passed in as arguments. The whole process is about 100 lines, built on the agent graph traversal methods in the SDK. The complete dispatcher.py is in the companion repo. The dispatcher carries the design in four parts: it builds the execution plan from the graph’s edges, composes each node’s input, runs every ready node concurrently each round, and records the graph’s metrics once per run. First, the dispatcher builds the execution plan from the graph’s edges, so the topology you draw in LaunchDarkly runs: Python for key, node in nodes.items(): for edge in node.get_edges(): target = edge.target_config if target in nodes: succ[key].append(target) preds[target].append(key) Next, every node receives the source papers and any upstream analyses, so each agent and the judge work directly from the source material rather than a summary handed down a chain: Python def compose_input(user_input, predecessor_outputs): parts = [f"=== SOURCE PAPERS ===\n{user_input}"] for key, out in predecessor_outputs: if out and out.strip(): parts.append(f"=== {key} ===\n{out}") return "\n\n".join(parts) Then each round runs every node whose predecessors have finished, concurrently, so the two readers fan out and fan in with no special casing: Python ready = [k for k in pending if all(p in done for p in preds[k])] results = await asyncio.gather(*(run_node(k) for k in ready)) Finally, the dispatcher records the graph’s metrics on each run, including the end-to-end latency the experiment ranks on: Python graph_tracker.track_duration(int((time.monotonic() - start) * 1000)) graph_tracker.track_total_tokens(TokenUsage(input=totals["in"], output=totals["out"], total=totals["in"] + totals["out"])) graph_tracker.track_path(path) graph_tracker.track_invocation_success() The dispatcher reads the topology at runtime, so reshaping the workflow in the UI, adding a node, or redrawing an edge takes effect on the next request with no code change. You’ll do exactly that in Step 5. Step 3: Each Framework Is a Thin Adapter Each framework implements build_agent(node_key, config, instructions) and async invoke(agent, input_text, tracker). Everything dynamic still comes from the LaunchDarkly node config: the model, the attached tools, and the instructions. LangGraph has a LaunchDarkly companion package, so its runner is only a few lines. The companion handles model creation, tool binding, and token tracking, so the adapter holds no framework plumbing of its own: Python def build_agent(node_key, config, instructions): llm = create_langchain_model(config) tools = build_tools(config, TOOL_REGISTRY) # binds only this node's attached tools return create_react_agent(llm, tools, prompt=instructions) async def invoke(agent, input_text, tracker): result = await tracker.track_metrics_of_async( lambda res: LDAIMetrics(success=True, tokens=sum_token_usage_from_messages(res.get("messages", []))), lambda: agent.ainvoke({"messages": [{"role": "user", "content": input_text}]}), ) messages = result.get("messages", []) for message in messages: for name in get_tool_calls_from_response(message): tracker.track_tool_call(name) text = _content_to_text(messages[-1].content) if messages else "" return text, sum_token_usage_from_messages(messages) Strands has no companion package, so its runner builds the model with a small provider-aware factory and binds tools with Strands’ native @tool. The contract is identical: Python def build_agent(node_key, config, instructions): return Agent( name=node_key, model=_create_strands_model(config), system_prompt=instructions or "Process the input and respond.", tools=_bind_tools(config), callback_handler=None, ) OpenAI Agents and Google ADK round out the four. For the comparison to stay fair, all four have to run the same model, but these two SDKs default to their own vendors’ models. LiteLLM, a thin adapter, lets them call any provider, so we point both at the pinned claude-sonnet-4-5 and keep the model identical across all four orchestrators. No OpenAI or Google servers are involved. Instead, LiteLLM translates the request format in-process, and the call goes straight to Anthropic with your key. Google ADK is fully companion-free, and OpenAI Agents uses the ldai_openai companion for token and tool-call telemetry even though it builds the model through LiteLLM. This experiment pins one model across all four frameworks, so every framework here runs Claude. Pointing each framework at its own vendor’s default model instead is a separate, optional exercise, the native-model bake-off in Step 9. The tool callables live in TOOL_REGISTRY, a plain {name: callable} map that each framework binds its own way. Step 4: Smoke Test the Graph Before you run any experiment, confirm the bootstrapped graph runs end to end. First, run one framework: Python uv run python orchestrators/verify_run.py langgraph It prints the path it took and the first part of the report. On the graph as it shipped, the path is intake -> approach-analyzer -> gap-synthesizer: intake runs its short pass, approach-analyzer reads the papers, and gap-synthesizer writes the report. There’s no contradiction-detector yet, and no error. The metrics land in the AgentControl UI under the graph you created. Step 5: Add the Parallel Fan-In In the UI Here’s the payoff of keeping the topology in LaunchDarkly: you finish building the workflow in the UI, with no redeploy, and the running app picks up the new shape on its next request. The contradiction-detector config already exists, with its fetch_paper tool attached. You wire it into the graph to add the second reader and form the parallel fan-in. To complete the graph: Click Agents in the LaunchDarkly sidebar.Click Agent graphs.Select research-gap-graph.Add the contradiction-detector node.Draw an edge from intake to contradiction-detector, then another from contradiction-detector to gap-synthesizer.Click Save. You add no routing logic: the edge itself is the route, because routing is structural. Re-run the smoke test: Shell uv run python orchestrators/verify_run.py langgraph The path now includes contradiction-detector, and because approach-analyzer and contradiction-detector run concurrently, their order can vary. You completed a multi-agent workflow from the UI, and the config you wired in already had its tool attached. You finished a multi-agent workflow from the UI, mid-development, and the dispatcher ran the new shape on the next request. No redeploy, no code change: the graph you draw is the graph that runs. Step 6: Smoke Test All Four Frameworks Before you collect experiment data, make sure all four frameworks can run the completed graph. One command runs all four in sequence: Shell uv run python orchestrators/verify_run.py all It runs each framework against the completed graph and ends with a pass/fail summary, one line per framework, exiting non-zero if any framework failed, so it works as a gate. Each framework prints the path it took and a preview of its report, then a final summary collects the results. A successful run looks like this: Plain Text ▶ Running 'langgraph' over 2 papers on graph 'research-gap-graph'... ✓ PATH : intake -> contradiction-detector -> approach-analyzer -> gap-synthesizer ▶ Running 'strands' over 2 papers on graph 'research-gap-graph'... ✓ PATH : intake -> contradiction-detector -> approach-analyzer -> gap-synthesizer ▶ Running 'openai-agents' over 2 papers on graph 'research-gap-graph'... ✓ PATH : intake -> contradiction-detector -> approach-analyzer -> gap-synthesizer ▶ Running 'google-adk' over 2 papers on graph 'research-gap-graph'... ✓ PATH : intake -> contradiction-detector -> approach-analyzer -> gap-synthesizer === smoke summary === ✓ langgraph ✓ strands ✓ openai-agents ✓ google-adk If a framework fails, its line shows an ✗ instead of a ✓ and the command exits non-zero. All four smoke tests against the pinned Claude model. ANTHROPIC_API_KEY is the only model key you need, because OpenAI Agents and Google ADK reach Claude through LiteLLM. The OpenAI Agents SDK turns on tracing by default and looks for OPENAI_API_KEY to export traces, so the openai-agents run may print a harmless tracing warning when that key is absent. It doesn’t affect the run. Step 7: Run It Through the Experiment Now you can use a LaunchDarkly experiment to rank the four frameworks on real traffic, on the same graph, with the model held constant. Because the model is fixed, the comparison is operational: which orchestrator delivers the model’s quality fastest, with the least token overhead. The bootstrap already created the flag, the judge, and the graph. These metrics are measured on each request, so do a one-time setup first: Make the request context kind available for experiments.Set the analysis unit of graph latency, tokens, and the judge metric to request. Then create the experiment in the UI: Create an experiment with the orchestrator flag as the treatment.Set the primary metric to Graph latency ($ld:ai:graph:duration:total, the time for a complete graph execution).Add tokens and $ld:ai:judge:gap-quality as secondary metrics.Set the audience to 100% and the randomization unit to request. Each run is a single request, there are no users in this workflow, and request is the unit LaunchDarkly measures AI and graph metrics by.Turn on the orchestrator flag, which the bootstrap created set to off, so it serves the experiment’s variations.Start an experiment iteration. We rank on latency and tokens because, with the model and the graph held constant, those are the things that genuinely differ: a framework can move quality only by degrading the plumbing, like a truncated report or a broken tool call. So $ld:ai:judge:gap-quality stays a guardrail that catches a framework “winning” by cutting corners, not part of the ranking. Swap the model, prompt, or tools later instead of the framework, and that same judge becomes your primary metric. Then drive traffic. The flag assigns each run one framework at random: Shell uv run python scripts/run_experiment.py --runs-per-category 6 That’s six runs over each of the six shipped topics, 36 in total. Assignment is random, so it usually fills all four variations, though it isn’t guaranteed. Each run analyzes the topic’s entire paper set, because gap analysis needs every paper to find real gaps. Open the experiment in LaunchDarkly: latency per variation, with tokens and $ld:ai:judge:gap-quality alongside. The winner is the framework with the best latency and lowest token use that doesn’t let quality slip. Because the model is pinned, cost is a fixed multiple of tokens, so the token column is also the cost ranking; for actual dollar figures, read them from Insights. Because the experiment holds everything but the framework constant, most of these bars land close, often within a few percent, which is by design. In our run, Strands won on speed: it ran the graph fastest, with quality holding at the guardrail. If you optimize for speed and quality holds, that makes Strands the orchestrator to ship for this workload. Six topics and one randomized split isn’t a large sample, so confirm the lead with more topics before you standardize on it. You can do that in Step 9. Step 8: Ship the Winner With Runtime Control The experiment gave you data. The reason to run it in LaunchDarkly, rather than a one-off script, is that acting on that data takes no deploy: the orchestrator flag that was the experiment treatment is also your production router. When a variation wins, stop the iteration and set the flag’s default to that framework. Every request routes to it on the next evaluation, with no redeploy. Then automate what you don’t want to babysit. An adaptive trigger watches a guardrail and changes a flag on its own when production drifts past it. The orchestrator you shipped is operational and won’t degrade by itself, so point the trigger at the model flag from Step 9: it fails over to a backup model when your primary provider has a bad day, the same guardrail driving a different flag. That closes the loop: experiment to find the winner, runtime control to ship it, and automation to keep it healthy. Step 9: Extend the Experiment Tighten the bands by adding more topics. Confidence comes from more distinct topics, not more runs over the same few. Download one with a title-phrase (ti:) query, and the harness picks it up automatically on the next run: Shell uv run python scripts/download_papers.py --query 'ti:"LLM-as-a-judge"' Make quality the headline by flipping a config, not a flag. The framework lives in the orchestrator flag because it is app-level routing, not a property of any agent. The model, the prompt, and the tool set are different: they live in the node configs, so you experiment on the config itself. Add a second variation to a node, such as gap-synthesizer with a stronger model or a tightened prompt, and run an experiment with that config as the treatment and its variations as the arms. Pin the framework by setting the orchestrator flag to one value and leave the graph alone, so the config is the only thing moving. The judge attached to the synthesizer already emits $ld:ai:judge:gap-quality, so quality is the primary metric with no new instrumentation. Now it genuinely moves, because a different model or prompt reasons differently about the same papers. Experiment on the graph shape with a graph-key flag. The dispatcher takes the graph key as an argument, so the shape is another value you can put behind a flag: Python graph_key = ld.variation("graph_shape", context, "research-gap-graph") result = await execute_graph(ai_client, graph_key, context, user_input, build_agent, invoke) Build two graphs with different keys: for example, a linear research-gap-graph-linear (intake to approach-analyzer to gap-synthesizer) against the parallel research-gap-graph, or one with an added critic node against one without. Make a multivariate graph_shape flag whose variations are those graph keys, evaluate it exactly as the app evaluates orchestrator, and set it as the experiment treatment with the framework and model held constant. You are measuring whether the extra structure earns its latency and quality, and because the dispatcher runs whatever shape the key resolves to, no runner or dispatcher code changes. You build the judge once, and it is the guardrail for the framework bake-off, and the headline metric for every model, prompt, tool, and shape you test next. Run a native-model bake-off. This experiment holds the model constant so the framework is the only variable. To compare each framework on its own default model instead, build separate node configs per framework. This is the optional bake-off the prerequisites mention. It’s a follow-up beyond this walkthrough, and the only part that needs OPENAI_API_KEY and GOOGLE_API_KEY. Whatever you flip, follow three rules: Change one variable at a time (the framework, the model, or the shape), never two. If you change more than one, you can’t attribute the win.Keep the quality guardrail on every run, because the fastest variant is often the one that quietly truncated its report or dropped a tool call.Earn confidence with distinct inputs, not repeats: a tight band around three repeated topics is still a tight band around the wrong number. To learn more about judge design, read When to add online evals and Evaluating with LLM-as-judge evaluators. To add a pre-production regression layer, read Offline evaluation of RAG-grounded answers. Recap and Next Steps Framework choice doesn’t have to be a one-way door. Put the topology in a LaunchDarkly agent graph, have each framework supply only build_agent and invoke, and let one experiment settle a question that usually gets answered by whoever argues hardest: pin the model, let the judge guard quality, and pick the orchestrator that delivers it fastest, with evidence in hand. Then keep going, because the framework is only the first swappable component. The same flag, experiment, and judge machinery compares models, prompts, tools, and whole graph shapes the same way, so “which is better” stops being a debate and becomes a measurement. And because the experiment and the runtime control are one flag, you never stop at a finding: you ship it, ramp it with a progressive rollout, and let an adaptive trigger hold the line in production while the AI iteration loop for reliable agents keeps the next change shipping behind eval gates. The complete code is in the sample repo. Get started with AgentControl, point the four frameworks at a graph your team actually runs, and settle the next framework argument with a number instead of a hunch.

By Scarlett Attensil
A Framework-Agnostic Approach to SSR for Microfrontends
A Framework-Agnostic Approach to SSR for Microfrontends

On one of our projects, we were building microfrontends, and at some point we wanted to add SSR. The reasons were the usual ones: better first paint, fewer layout shifts, real content for crawlers, less JS to load before something appears on screen. Setting it up turned out to be harder than I expected. There was no obvious out-of-box path that fit our setup, and most of the approaches I found either assumed a shared build or asked us to add new infrastructure on top of what we already had. That is what made me start sketching a small package. Something any team could drop in and get SSR for their microfrontend without rewriting either side. The result is @mf-toolkit/mf-ssr. The rest of this is about the approach behind it, since I think that is the interesting part. What I Wanted I started from a short list, taken straight from how I'd want to use such a thing: MF content on first paint. The remote's HTML should arrive inside the host's server response, not be fetched from the client after JS loads. No empty slot, no layout shift, real content in crawlers.No shared build, no central orchestrator. Each team builds and deploys their remote on their own schedule. The host should not need a special Node process that imports every remote into one bundle, and remote teams should not need to rewrite their bundler config to fit a central setup.Two paths for two setups, one host component. I wanted both scenarios covered. url mode for when the remote team runs their own server and wants to own SSR on their side (and possibly use a non-React framework). loader mode for when the remote only ships a static React bundle and the host server can do the SSR for it. The host code should look almost the same in either case, with just a single prop telling the component which path to use.Any framework, any runtime. The remote might be React, but it could be Vue, Svelte, or anything else. The host shouldn't care. And on the server, the same code should run on Node, Bun, Cloudflare Workers, or Vercel Edge with no rewrites.Host state still drives the remote after hydration. When the host re-renders with new props, the remote should re-render too. No re-fetch, no re-mount, no shared store between bundles.Honest failure modes. A timeout when the remote is slow, retry when a request fails, an explicit fallback for total failure, and a cache that respects auth boundaries. The things that decide whether SSR is a win or a regression when one team has a bad deploy. The last bullet is what most articles skip. SSR is easy in the happy path. The interesting code is what happens when one of the remotes is slow, down, or returning garbage. How It Works The idea is small: Instead of importing remote components into the host server, the host pulls the rendered output in over HTTP at SSR time and streams it into its own response. The browser gets a full page on first paint. How that "pull" happens depends on how the remote is deployed. The package supports two modes for that: url mode – the remote has its own HTTP endpoint that returns rendered HTML. The host fetches that HTML during SSR.loader mode – the remote is a static React bundle on a CDN or S3, no server behind it. The host imports the component directly during SSR and renders it inline. Same host component (<MFBridgeSSR>) in both cases, just one prop changes. Both modes can live on the same page. The interesting part is what happens after hydration. The host has to push prop changes into the remote without re-fetching anything. I will get to that in a moment. I'll start with url mode since it is the more general case (any framework on the remote side, any runtime on the server), and then cover loader mode separately. url mode: Remote With Its Own HTTP Endpoint In url mode, the remote server does the SSR. The remote team runs their own runtime (Node, Bun, a Cloudflare Worker, a Next.js Route Handler, whatever they prefer) and exposes an HTTP endpoint that returns rendered HTML for the given props. The host's SSR pass just calls that endpoint and inlines the response into the page. Each microfrontend owns its own rendering pipeline. Remote Handler TypeScript-JSX import { createMFReactFragment } from '@mf-toolkit/mf-ssr/fragment' import { CheckoutWidget } from './CheckoutWidget' export const handler = createMFReactFragment(CheckoutWidget) handler is a plain Web fetch handler: (req: Request) => Promise<Response>. It reads props from the query string, renders the component to a stream with renderToReadableStream, and writes the props into a small <script> tag so the client can hydrate without going back to the network. One nuance worth flagging: those props go inside a <script> tag, so a raw </script> inside a string prop would close the tag prematurely and let user-controlled values escape into the HTML context. The handler escapes <, >, &, and U+2028/U+2029 to their \uXXXX equivalents before embedding. JSON.parse on the client treats them the same as the originals, but the browser's HTML parser never sees a closing tag. It is a few lines of code that close a real XSS hole. You wire the handler into whatever HTTP framework the remote team already uses. Hono, a Next.js Route Handler, Bun, plain Node, a Cloudflare Worker. The handler doesn't know about any of them. And because the whole thing is Web Streams, it runs on Cloudflare Workers, Vercel Edge, Bun, and Node 18+ without changes. Non-React Remotes createMFReactFragment is a React-only helper. If the remote is Vue, Svelte, Solid, or vanilla JS, the team writes their own fetch handler instead, but it has to produce the same HTML shape the host expects: TypeScript-JSX <div data-mf-ssr="checkout"> <script type="application/json" data-mf-props>{"orderId":"42"}</script> <div data-mf-app><!-- Vue / Svelte / whatever rendered HTML --></div> </div> The team uses their framework's SSR renderer (renderToString for Vue, Svelte's SSR API, and so on) to produce the inner HTML, and serializes props into the <script data-mf-props> tag, applying the same < / > / & escaping. On the client, the remote mounts itself into [data-mf-app] and reads initial props from [data-mf-props]. If it needs prop updates from the host after hydration, it listens on the same DOMEventBus (exported from @mf-toolkit/mf-bridge). The bus is a thin wrapper over native CustomEvent, with no React dependency, so it works fine for any framework. This path is more work than createMFReactFragment, but the contract is small and explicit. The host doesn't care which framework produced the inner HTML — as long as the wrapper structure matches, hydration finds the right slots. Host Component TypeScript-JSX <MFBridgeSSR url="https://checkout.acme.com/fragment" namespace="checkout" props={{ orderId, step } fallback={<CheckoutSkeleton />} /> During SSR, the host fetches the remote's HTML and streams it into the response. Each <MFBridgeSSR> lives in its own Suspense boundary, so a slow checkout doesn't block the header. They stream as they resolve. On the client, the host hydrates, then waits for prop changes coming from React. Prop Updates After Hydration This was the part I cared about most. The remote is in its own React root, often in its own bundle, sometimes in a completely different framework. You can't re-render it like a normal child. So I used the one thing both sides already share at runtime: the DOM node the remote is mounted into. When the host re-renders with new props, the host fires a CustomEvent on that node. The remote listens for it and re-renders its root with the new props. No re-fetch, no global state, no coupling between bundles beyond a shared namespace string. TypeScript-JSX // remote client entry import { hydrateWithBridge } from '@mf-toolkit/mf-bridge/hydrate' import { CheckoutWidget } from './CheckoutWidget' hydrateWithBridge(CheckoutWidget, { namespace: 'checkout' }) I picked this because it is isolated by construction. If a page has several MF slots, each one has its own mount node, so events never leak between them. And it is just DOM, so there is no bundler magic to debug when something goes wrong. Events and Commands Prop streaming is one direction. For the other direction, the same bus works in reverse. The host passes onEvent to receive events the remote emits, and a commandRef it can use to send imperative commands back: TypeScript-JSX const resetRef = useRef<((type: string, payload?: unknown) => void) | null>(null) <MFBridgeSSR url="https://checkout.acme.com/fragment" namespace="checkout" props={{ orderId } onEvent={(type, payload) => { if (type === 'orderPlaced') navigate('/thanks') } commandRef={resetRef} /> // somewhere in host code, e.g. when the user switches accounts: resetRef.current?.('reset') On the remote, hydrateWithBridge accepts an onCommand handler, and DOMEventBus (exported from @mf-toolkit/mf-bridge) lets the remote send events back: TypeScript-JSX import { hydrateWithBridge } from '@mf-toolkit/mf-bridge/hydrate' import { DOMEventBus } from '@mf-toolkit/mf-bridge' hydrateWithBridge(CheckoutWidget, { namespace: 'checkout', onCommand: (type) => { if (type === 'reset') store.reset() }, }) // inside the widget, after a successful payment: const container = document.querySelector<HTMLElement>('[data-mf-namespace="checkout"]')! new DOMEventBus(container, 'checkout').send('event', { type: 'orderPlaced', payload: { orderId }, }) The channel is the same DOMEventBus, just with extra event names on top of propsChanged. So everything I said earlier about isolation still holds: events on one slot don't reach another, even when the remote is the same. loader mode: Remote as a Static Bundle In loader mode, the host server does the SSR for the remote. The remote team ships only a static React bundle (CDN, S3, or a Module Federation host) and runs no server of their own. When the host renders its page server-side, it imports the remote component and renders it inline, the same way it renders any other component in the host tree. The remote has no SSR runtime and no rendering responsibility; the host does all the work.ё Host Component JSX const loadCheckout = () => import('checkout/Widget').then(m => m.CheckoutWidget) <MFBridgeSSR loader={loadCheckout} props={{ orderId, step } fallback={<CheckoutSkeleton />} /> That is everything. No namespace, no errorFallback tricks needed for hydration, no client entry to write on the remote side. The package wraps the loader in React.lazy and renders the component inside the host's React tree, both server-side and after hydration. Props, Events, Commands Since the remote lives inside the host's React tree, every kind of communication is just React: Props – re-render normally. When the host's parent component re-renders with new props, the remote re-renders too. No DOMEventBus, no hydrateWithBridge, no propsChanged events.Events from remote to host – pass a callback through props. The remote calls it like any other handler.Commands from host to remote – pass them through props as well, or expose a ref through forwardRef. If you find yourself wanting onEvent / commandRef here, you are probably reaching for url mode. Requirements A few constraints come with this mode: Host must be able to resolve the loader on the server. The package calls your loader() function as-is. It doesn't fetch bundles from URLs itself. In practice, this means Module Federation runtime on the host (or some other server-side dynamic import mechanism that knows how to find checkout/Widget). Without that, the import fails in Node before any rendering happens.React only. The host literally calls the component during SSR, so the remote has to be a React component. For Vue/Svelte/vanilla remotes, use url mode.SSR-safe import. The remote's exposed module has to be importable on the server, which means no window, document, or other browser globals at the module top level. Move that code inside useEffect or behind a typeof window check.Stable loader reference. Define loadCheckout at module scope or wrap it in useCallback. The package caches the resulting React.lazy by loader reference so Suspense retries reuse the same promise. A new function on every render would break that and trigger an infinite retry loop. When to Pick Which CategoryURL modeLoader modeRemote infrastructureOwn HTTP endpoint: Node.js, Bun, Worker, etc.Static bundle on CDN, S3, or Module Federation hostRemote frameworkAny: React, Vue, Svelte, vanilla JavaScriptReact onlyIsolationSeparate React root inside the remote bundleRendered inline in the host React treeProp updatesDOM events through DOMEventBusNative React re-renderEvents and commandsonEvent and commandRefReact props and refsBest forIndependent teams, mixed frameworks, and polyreposSimple React remotes with no extra infrastructure Both modes use the same <MFBridgeSSR> and can be mixed freely on the same page. The Corner Cases I Spent Time On A few production scenarios I wanted to make sure the package handled honestly. Graceful Degradation When the Remote Is Down A remote can be slow, return a 5xx, or simply not respond. The host page shouldn't break because of one bad slot. mf-ssr accepts an errorFallback, and the trick is that the fallback can be the same remote mounted on the client through mf-bridge: TypeScript-JSX import { MFBridgeSSR } from '@mf-toolkit/mf-ssr' import { MFBridgeLazy } from '@mf-toolkit/mf-bridge' <MFBridgeSSR url="https://checkout.acme.com/fragment" namespace="checkout" props={{ orderId } timeout={2000} errorFallback={ <MFBridgeLazy register={() => import('checkout/entry').then(m => m.register)} props={{ orderId } fallback={<CheckoutSkeleton />} /> } /> If the SSR fetch times out, the user still gets the widget. Just on the client, the same way it would have worked without mf-ssr at all. The page doesn't break. The slot loses its first-paint optimization, for that one request. When the remote recovers, the next render uses SSR again with no code change on either side. I like this case because it inverts the usual SSR-or-nothing tradeoff. SSR becomes the fast path, with a working client-side path sitting right behind it. Auth-Isolated Caching The host caches fragments by url + props + timeout. Fine for public content. Not fine when each user gets different HTML — they would share a cache slot and see each other's pages. So there is a cacheKey prop you set when the request carries auth: TypeScript-JSX <MFBridgeSSR url="https://account.acme.com/fragment" namespace="account" props={{ view: 'orders' } fetchOptions={{ headers: { authorization: `Bearer ${token}` } } cacheKey={userId} /> The other side of the same coin is public fragments. The remote's fragment endpoint accepts a cacheControl option, so you can serve a product card as public, s-maxage=60, stale-while-revalidate=30 and let a CDN cache it for everyone: TypeScript-JSX export const handler = createMFReactFragment(ProductCard, { cacheControl: 'public, s-maxage=60, stale-while-revalidate=30', vary: 'Accept-Language', }) One pattern handles per-user fragments, the other handles cacheable public ones. Same component on both sides. Multiple Instances of the Same Remote Header, sidebar, and a content slot can all be the same remote on one page. The reason I sent prop updates through the mount DOM node, instead of a global event bus, is exactly this case: each <MFBridgeSSR> has its own DOM node, so events stay scoped to it. No filtering by instance id, no manual subscription bookkeeping. Warming the Cache From RSC If you know a fragment is going to be needed, you can start the fetch before <MFBridgeSSR> even renders. Suspense then skips the fallback entirely: TypeScript-JSX import { preloadFragment } from '@mf-toolkit/mf-ssr' // In a Server Component or route loader preloadFragment('https://checkout.acme.com/fragment', { orderId }) By the time the component renders down the tree, the HTML is already there. Where It Fits If your microfrontends share one build (a single bundler config that imports every remote), you don't need any of this. Use whatever your framework gives you. mf-ssr is for the case where each team builds and deploys independently. Different repos or not, the point is that there is no shared build step pulling everything into one Node process — and you still want a full page on first paint. The bet is that HTTP is a good enough boundary between teams, and that DOM events are a good enough way to keep host state in sync with remote rendering after hydration. The CSS isolation question, by the way, lives in mf-bridge, not here: it has shadowDom and adoptHostStyles props that wrap the remote in a Shadow DOM and forward host stylesheets (including Tailwind / CSS-in-JS chunks injected after mount) into the shadow root. SSR fragments don't use it by default since the HTML is inlined into the host response, but the option exists if you want it. Try It The package is published as @mf-toolkit/mf-ssr. The repo has runnable examples, and I've also made a demo repo where you can play with all my tools. If you've solved the same problem in a different way, I'd be curious to compare notes.

By Vitaly Zheltko
A Zero-Trust Implementation Framework for Cloud Migrations: Lessons From Enterprise Deployments
A Zero-Trust Implementation Framework for Cloud Migrations: Lessons From Enterprise Deployments

Cloud migration projects almost always treat security as a downstream concern something to bolt on after workloads have already moved, once the “real” migration work is done. Across dozens of enterprise migrations spanning finance, healthcare, and manufacturing workloads, that ordering is consistently the source of the costliest rework: reopened firewall rules, retrofitted identity models, and access reviews that should have happened before a single virtual machine was provisioned. The pattern holds regardless of which cloud provider is on the receiving end. What follows is a framework provider-agnostic by design for embedding zero-trust principles into the migration process itself, rather than applying them after the fact. Why Bolt-On Security Fails Traditional migration playbooks are organized around workload movement: discover, assess, re-platform, cut over, optimize. Security tasks are usually inserted late, as a checklist item before go-live. Three consequences follow reliably: Implicit trust survives the move. Implicit trust survives the move. On-premises networks often rely on perimeter trust: anything inside the firewall is assumed safe. When that assumption is lifted-and-shifted into the cloud without redesign, the perimeter simply becomes larger and harder to defend.Identity sprawl compounds. Identity sprawl compounds. Migrations frequently multiply service accounts, temporary roles, and cross-environment credentials used to bridge on-prem and cloud during cutover. Few of these get cleaned up.Retrofitting is expensive. Retrofitting is expensive. Segmenting a network or re-scoping IAM roles after hundreds of workloads are already live requires downtime windows and change approvals that could have been avoided by designing correctly the first time. The Framework: 4 Pillars, Applied in Migration Order The framework below organizes zero-trust adoption into four pillars, sequenced to match the natural phases of a migration rather than treated as a parallel workstream. 1. Identity as the New Perimeter Before any workload assessment begins, establish the identity model the migrated environment will use, not the one the source environment happens to have. Define role-based access aligned to job function, not to legacy group membership inherited from the source directory.Require multi-factor authentication for every administrative path into the target environment before migration tooling is granted access, not after.Treat every migration-tooling service account as temporary by default, with an explicit expiration and re-certification date. 2. Segment Before You Migrate, Not After Network segmentation decisions made during the assessment phase are cheap. The same decisions made post-migration require change windows and stakeholder sign-off. Group workloads into trust tiers during discovery (e.g., internet-facing, internal-only, regulated-data) rather than assuming a flat network topology will be corrected later.Design micro-segmentation boundaries around workload tiers before the first server moves, so that day-one network policy already reflects least-privilege communication paths.Validate east-west traffic rules against actual application dependency maps, not assumed ones; dependency mapping tools exist for this precisely because assumptions are usually wrong. 3. Encrypt and Verify at Every Hop, Not Just at Rest Most cloud providers make encryption at rest close to a default setting. The gap is almost always in transit and in verification. Require mutual TLS or equivalent between service-to-service calls introduced during migration, especially temporary bridging connections between source and target environments.Treat data classification as a migration input, not a post-migration audit finding. Classify before you move, so encryption and access policy can be applied by tier from day one.Build verification checkpoints into the cutover plan itself: an environment isn't “migrated” until its access logs confirm no implicit-trust paths remain from the legacy network. 4. Assume Breach, Instrument Accordingly The final pillar is operational rather than architectural: build the assumption of compromise into monitoring from the start of the migration, not after an incident. Instrument logging and alerting for the target environment before cutover, so that abnormal access patterns are visible from hour one rather than backfilled weeks later.Run tabletop exercises against the migrated architecture; specifically, lessons from the legacy environment's incident response plan rarely transfer cleanly.Track a small set of leading indicators (privileged session anomalies, unexpected cross-tier traffic, credential reuse across environments) rather than waiting for a full SIEM rollout to catch up. Lessons From Enterprise Deployments A few patterns show up consistently across large, regulated deployments: Sequencing beats scope. Organizations that tried to implement all four pillars simultaneously across an entire estate stalled. The deployments that succeeded phased identity and segmentation first, then layered encryption verification and monitoring in as workloads landed.Legacy exceptions need sunset dates. Legacy exceptions need sunset dates. Every migration produces temporary trust exceptions to keep the business running during cutover. Without a hard expiration date attached at creation, these exceptions become permanent attack surface.Cross-functional ownership matters more than tooling. Cross-functional ownership matters more than tooling. The deployments with the fewest post-migration security incidents were the ones where network, identity, and application teams jointly signed off on the trust model before migration started, not the ones with the most sophisticated tooling. Common Pitfalls Treating zero trust as a product purchase rather than an architectural discipline applied throughout the migration lifecycle.Migrating identity and network configuration as-is with the intention to “harden it later” rarely comes without an incident forcing it.Measuring migration success purely on workload count and timeline, with security posture reviewed only at the end. Closing Thought Zero trust and cloud migration are often treated as separate initiatives running on separate timelines. The organizations that get the best outcomes fewer post-migration incidents and faster time-to-secure-operations are the ones that treat zero trust as a design constraint on the migration itself, sequenced into discovery, assessment, and cutover rather than appended afterward. The framework above is intentionally provider-agnostic because the discipline it describes identity first, segmentation before movement, verification at every hop, and instrumentation from day one holds regardless of which cloud the workloads land on.

By Srinivasarao Thumala
TensorFlow vs PyTorch: The Real Difference Isn’t Accuracy
TensorFlow vs PyTorch: The Real Difference Isn’t Accuracy

A few days ago, I set out to build a simple image classification model using convolutional neural networks (CNNs). The task itself wasn’t particularly complex, but choosing the right framework proved more challenging than expected. I found myself choosing between TensorFlow and PyTorch, two powerful frameworks for building high-performance CNNs. To explore this, I implemented the same CNN in both frameworks under identical conditions and compared them across key aspects like learning curve, flexibility, debugging, and performance. A Quick Look at the Frameworks Before deep-diving into the comparison, it’s worth briefly understanding the two frameworks used throughout this experiment. 1. TensorFlow TensorFlow is an open-source deep learning framework developed by Google. It is widely known for its strong ecosystem and production-ready capabilities. One of its key strengths is its integration with high-level APIs such as Keras, which simplifies model building and training. TensorFlow is commonly used in large-scale applications, offering tools for deployment across web, mobile, and edge devices. Overall, it is often preferred when moving models from experimentation to production environments. 2. PyTorch PyTorch is an open-source deep learning framework developed by Meta Platforms. It has gained significant popularity, especially in the research community, due to its simplicity and flexibility. PyTorch uses a dynamic computation graph, which makes it feel more like standard Python code. This makes model development more intuitive and debugging significantly easier. It is often the preferred choice for experimentation, rapid prototyping, and research-driven projects. Experiment Setup To ensure a fair and meaningful comparison between TensorFlow and PyTorch, both implementations were designed under identical conditions. 1. Dataset The models were trained and evaluated on the CIFAR-10 dataset, a widely used benchmark for image classification tasks.It consists of 60,000 color images across 10 classes, making it suitable for evaluating CNN performance.CIFAR-10 is publicly available for research purposes and is commonly distributed under a permissive academic license, allowing free use for educational and non-commercial applications. 2. Model Architecture A simple yet effective Convolutional Neural Network (CNN) architecture was used in both frameworks. The structure includes: Convolutional layers for feature extractionReLU activation functionsMax-pooling layers for dimensionality reductionFully connected layers for classification Care was taken to ensure that the architecture remained identical in both implementations. 3. Training Configuration To maintain consistency, the following hyperparameters were used across both frameworks: Optimizer: AdamLearning rate: 0.001Batch size: 64Number of epochs: 10Loss function: Cross-Entropy Loss 4. Environment All experiments were conducted using Google Colab. Both TensorFlow and PyTorch implementations were executed in the same runtime environment. The configuration used includes: Runtime Type: GPU-enabled environmentPython Version: 3.xDeep Learning Libraries: TensorFlow and PyTorch (latest stable versions) The experiments were run on the same Colab runtime session to maintain consistency in resource allocation. Implementation To ensure a fair comparison, the same CNN architecture and training configuration were implemented using both TensorFlow and PyTorch. While the underlying model remains identical, the implementation approach differs significantly across the two frameworks. 1. CNN Implementation in TensorFlow The model was first implemented using TensorFlow with its high-level Keras API, which provides a concise and structured way to define deep learning models. Model Definition Python model = models.Sequential([ layers.Conv2D(32, (3,3), activation='relu', input_shape=(32,32,3)), layers.MaxPooling2D((2,2)), layers.Conv2D(64, (3,3), activation='relu'), layers.MaxPooling2D((2,2)), layers.Flatten(), layers.Dense(64, activation='relu'), layers.Dense(10, activation='softmax') ]) The Sequential API allows layers to be stacked in a linear fashion, making the architecture easy to read and implement. This significantly reduces boilerplate code and is especially helpful for beginners. Model Compilation and Training Python model.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy']) history = model.fit(x_train, y_train, epochs=10, batch_size=64, validation_data=(x_test, y_test)) Training in TensorFlow is handled using a single high-level function. It automatically manages the training loop, backpropagation, and metric tracking, making the process highly streamlined. Observation: TensorFlow offers a compact and beginner-friendly implementation. With minimal code, it handles most of the underlying complexity, making it ideal for rapid development and production-oriented workflows. 2. CNN Implementation in PyTorch The same CNN architecture was implemented using PyTorch, which follows a more explicit and flexible approach. Model Definition Python class CNN(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(3, 32, 3) self.pool = nn.MaxPool2d(2,2) self.conv2 = nn.Conv2d(32, 64, 3) self.fc1 = nn.Linear(64*6*6, 64) self.fc2 = nn.Linear(64, 10) In PyTorch, models are defined using Python classes. This provides greater flexibility but requires a more detailed understanding of how each component works. Forward Pass Python def forward(self, x): x = self.pool(torch.relu(self.conv1(x))) x = self.pool(torch.relu(self.conv2(x))) x = x.view(-1, 64*6*6) x = torch.relu(self.fc1(x)) x = self.fc2(x) return x The forward pass must be explicitly defined, giving full control over how data flows through the network. This makes it easier to customize and debug complex models. Training Loop Python for inputs, labels in trainloader: optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() Unlike TensorFlow, PyTorch requires a manual training loop. While this increases the amount of code, it also provides complete transparency and control over the training process. Observation: PyTorch offers a more flexible and transparent approach. Although it requires more code, it allows finer control over model behavior, making it a preferred choice for experimentation and research. With both implementations in place, the next step is to evaluate their performance and analyze how they compare across different metrics. Results and Analysis With both implementations completed under identical conditions, we now compare TensorFlow and PyTorch using empirical results and practical observations. 1. Accuracy The image illustrates the Accuracy and Training Time for TensorFlow and PyTorch. (Image by Author) Both frameworks achieved nearly identical performance on the CIFAR-10 dataset: TensorFlow Accuracy: 68.78% PyTorch Accuracy: 68.95% The difference (0.17%) is extremely small and falls within normal training variation. When architecture, data, and hyperparameters are controlled, the choice of framework has virtually no impact on model accuracy. Additionally, both models show: Consistent improvement across epochsNo signs of severe overfittingStable generalization on test data The image illustrates the Train and Test accuracy for TensorFlow and PyTorch. (Image by Author) 2. Loss Convergence The image illustrates the Loss Convergence for TensorFlow and PyTorch in Logarithmic Scale. (Image by Author) TensorFlow exhibits a smooth and gradually decreasing loss, both for training and validation.PyTorch shows a similar downward trend, but with slightly larger values. The higher loss values in PyTorch are due to loss accumulation across batches, whereas TensorFlow reports average loss per epoch. Despite differences in scale, both frameworks demonstrate stable and consistent convergence behavior, indicating effective training. 3. Model Training Performance Training Speed TensorFlow: 715.23 secondsPyTorch: 723.31 seconds TensorFlow is slightly faster (~1% difference), but the gap is minimal For moderate-sized datasets like CIFAR-10, training speed differences are negligible and unlikely to influence framework selection, but TensorFlow provides strong tooling for large-scale deployment, while PyTorch is equally capable in training large models. 4. Scalability and Flexibility TensorFlow follows a more structured and predefined approach, but provides robust tools such as distributed training and deployment pipelines. It also holds an advantage in large-scale production environments, while PyTorch continues to close the gap. PyTorch uses a dynamic computation graph, allowing runtime modifications, which makes custom modifications easy. It is better suited for research and experimentation, where flexibility is critical. 5. Learning Curve From an implementation standpoint: TensorFlow (via Keras) allows model creation with minimal and structured code; hence, it is easier to start with.PyTorch requires explicit definitions for model architecture, forward passes, and training loops; this results in lengthier code and greater initial effort. Ultimately, the choice between TensorFlow and PyTorch is less about performance and more about how you prefer to design, experiment with, and deploy deep learning models. Choosing Between TensorFlow and PyTorch TensorFlow is better suited when working on production-ready systems, where scalability, deployment tools, and a structured workflow are important. Its high-level APIs make it easy to develop models quickly and integrate them into real-world applications, including mobile and edge environments.PyTorch is more appropriate for research and experimentation, where flexibility and control are critical. Its dynamic nature and seamless debugging experience make it ideal for testing new ideas and building custom architectures. Conclusion: Choosing the Right Framework Through this hands-on comparison of TensorFlow and PyTorch using a CNN on the CIFAR-10 dataset, one key insight becomes clear: both frameworks perform almost identically when it comes to core metrics. The experimental results showed: Nearly identical accuracy (~68–69%)Comparable training timesSimilar loss convergence patterns This highlights an important takeaway: The choice of framework has little to no impact on model performance when architecture and training conditions are kept consistent. However, the real difference lies not in performance, but in how you build, debug, and deploy models. Ultimately, the best framework is not the one that performs slightly better on benchmarks, but the one that aligns with your workflow, problem domain, and development style. Connect with me for more updates: MediumLinkedIN

By Rakshath Naik
Rethinking Java Design Patterns: From OOP to FP
Rethinking Java Design Patterns: From OOP to FP

The functional programming answer, to those who wonder how to integrate or combine it with object-oriented programming, is usually: Turtles all the way down. This is an aphorism whose origin is credited to Richard Feynman. In his book, Surely You're Joking, Mr. Feynman !, published in 1985, he tells the story of one of his conferences on the nature of the universe, where he was challenged by someone in the audience, saying that the universe rests on a turtle. Feynman asked then what the turtle is resting on, and the answer was: "another bigger turtle". And when he smugly asked what the bigger turtle is resting on, the attendee said: "It's turtles all the way down, you can't trick me !" This metaphor is often used in the context of functional programming to describe an infinite series of entities governed by a recursive principle. And it's also the answer of functional programming to developers coming from an object-oriented mindset: "just do functional all the way down." But to adopt a more systematic approach to combining object-oriented principles with a functional style, a more practical answer is required, and this is what I'm trying to do here. We, as developers, fortunately don't have to reinvent the wheel. All the problems are solved nowadays, especially since LLM agents became the most common digital infrastructure. But as surprising as it might seem to our younger colleagues, who can't live 48 hours without AI, even before LLMs, a general approach fitting solutions to problems existed, in the form of design patterns. As a matter of fact, object-oriented programming proposes repeatable solutions tested, proven, and formalized, called design patterns, that you most likely already used, even if you aren't aware of it. The Gang of Four classified these patterns into three groups: Behavioral patterns, which deal with responsibilities and communication between objects.Creational patterns that abstract the object creation/instantiation process.Structural patterns that compose objects such that they form larger or enhanced ones. Let's take some of the most commonly used patterns in each category and see how to combine their object-oriented inherent nature with a more functional approach. The Factory This design pattern belongs to the creational category, and its purpose is to instantiate objects without exposing implementation details. The Object-Oriented Approach The figure below shows the class diagram of a factory design pattern: Our scenario here is a simple one: a Product interface implemented by three classes: BookProduct, ElectronicProduct and FashionProduct. They can be created through the ProductFactory class, as follows: Java public class ProductFactory { public static Product newProduct (String name, String description, BigDecimal price, ProductType productType) { Objects.requireNonNull(name, "Name is null"); ... return switch (productType) { case BOOK -> new BookProduct(name, description, price); case ELECTRONIC -> new ElectronicProduct(name, description, price); case FASHION -> new FashionProduct(name, description, price); default -> throw new IllegalArgumentException ("Unknown type: %s".formatted(productType)); }; } } Using this factory, it's very easy to create a BookProduct, for example, while avoiding to expose implementation details: Java ... Product product = ProductFactory.newProduct("Book1", "A book", new BigDecimal("20.50"), ProductType.BOOK); ... As you probably noticed, the ProductType enumerated defines the three categories. If a new product is to be introduced, the factory has to be modified to reflect this business change. And this interdependence of the factory and the enumerated makes the whole approach fragile. In order to reduce this fragility, we need to introduce a compile-time validation with a more functional approach. The Functional Approach Our example is an over-simplified case of a product management system. The presented factory instantiates different simple records having the same arguments. These identical constructors give us the possibility to move the factory directly into the ProductType enumerated, such that any new product automatically requires a corresponding factory. Java enum types are based on constant names, but we can attach to each one its corresponding value. Or, even better, a factory function for creating discrete products. Look at that: Java public enum ProductType { ELECTRONIC(ElectronicProduct::new), FASHION(FashionProduct::new), BOOK(BookProduct::new); public final TriFunction<String, String, BigDecimal, Product> factory; ProductType (TriFunction<String, String, BigDecimal, Product> factory) { this.factory = factory; } public Product newInstance (String name, String description, BigDecimal price) { Objects.requireNonNull(name, "Name is null"); ... return this.factory.apply (name, description, price); } } Now, creating a new Product instances is easier: Java Product product = ProductType.BOOK.newInstance("Book1", "A book", new BigDecimal("20.45")); The public property factory seems redundant now that a dedicated method for the instance creation is available. But it provides a very convenient functional way to interact further with the factory. For example: Java ProductType.BOOK.factory.andThen(showThePrice).apply("Book1", "A book", new BigDecimal("20.45")); as shown in the TestProductFactory class, in the fp_design_paterns.factorypackage. Of course, given that our products need three-argument constructors and since Java doesn't provide an equivalent of the BiFunction class, but with three input arguments, you will need to craft a TriFunction class, as shown below: Java @FunctionalInterface public interface TriFunction<A, B, C, R> { R apply(A a, B b, C c); default <K> TriFunction<A, B, C, K> andThen(Function<? super R, ? extends K> f) { Objects.requireNonNull(f); return (A a, B b, C c) -> f.apply(apply(a, b, c)); } } You can do that or, if like me, you prefer to use a reliable library, then Vavr already defines a Function3 interface that has the behavior you want. Just include the following Maven dependency: XML <dependency> <groupId>io.vavr</groupId> <artifactId>vavr</artifactId> <version>1.0.1</version> </dependency> This library is a good choice if you need to define functions with up to 8 arguments. Then, you just need to replace, in ProductType, the following definition: Java public final TriFunction<String, String, BigDecimal, Product> factory; ProductType (TriFunction<String, String, BigDecimal, Product> factory) { this.factory = factory; } by this one: Java public final Function3<String, String, BigDecimal, Product> factory; ProductType (Function3<String, String, BigDecimal, Product> factory) { this.factory = factory; } The Visitor This design pattern belongs to the behavioral category and its purpose is to add new operations to an existing object hierarchy without modifying the classes of that hierarchy. It is the classic answer to the expression problem: When the set of types is stable, but the set of operations grows, the Visitor lets you keep adding operations cheaply. We reuse the same domain as the factory: a Product implemented by BookProduct, ElectronicProduct and FashionProduct. To give the visitor a reason to exist, each operation now behaves differently per product type: VAT: a reduced 5.5% rate for books, the standard 20% rate otherwise.Shipping: 10.00 + 2% of the price for (fragile, insured) electronics, a flat 3.00 for books and a flat 5.00 for fashion.Discount: 10% for electronics, 5% for books, 15% for fashion. The Object-Oriented Approach The classic Visitor relies on double dispatch. Each Product accepts a visitor and calls back the overload matching its own type: Java public interface Product { ... <R> R accept(ProductVisitor<R> visitor); } public record BookProduct (String name, String description, BigDecimal price) implements Product { ... public <R> R accept(ProductVisitor<R> visitor) { return visitor.visit(this); } } The operation lives in a generic visitor, one `visit` overload per concrete type: Java public interface ProductVisitor<R> { R visit(ElectronicProduct product); R visit(BookProduct product); R visit(FashionProduct product); } Computing the VAT of any product is then a matter of applying a concrete visitor: Java BigDecimal vat = book.accept(new VatVisitor()); Adding a new operation (shipping, discount, ...) only requires a new ProductVisitor implementation as the Product implementation classes never change. This is the reverse of the trade-off the factory made: it made adding a new operation easy, but a new product type is more expensive to add as you must edit its central switch. The visitor makes adding a new operation free but shifts that same cost onto types, since a new product type now forces every visitor to be updated. It is the classic expression problem: you can make types cheap to add or operations cheap to add, but not both. The following figure shows the object-oriented implementation class diagram: The Functional Approach Look now at the class diagram of the Visitor functional style implementation: In modern Java, the functional counterpart of the Visitor is exhaustive pattern matching over a sealed type. We first seal the hierarchy: Java public sealed interface Product permits ElectronicProduct, BookProduct, FashionProduct { ... } An operation is then just a Function<Product, R> built on a switch that deconstructs each record. Because Product is sealed, the compiler proves the switch is exhaustive — no default branch, no double dispatch, no accept: Java public static final Function<Product, BigDecimal> VAT = product -> switch (product) { case BookProduct(String name, String description, BigDecimal price) -> amount(price, "0.055"); case ElectronicProduct(String name, String description, BigDecimal price) -> amount(price, "0.20"); case FashionProduct(String name, String description, BigDecimal price) -> amount(price, "0.20"); }; Being ordinary functions, these operations compose: Java ProductOperations.DISCOUNT.andThen(amount -> "discount=" + amount).apply(fashion); Between the classic Visitor and pure pattern matching sits an intermediate step: the visitor as a bundle of functions, one lambda per type, instead of an interface with one method per type: Java public record ProductVisitor<R>( Function<ElectronicProduct, R> onElectronic, Function<BookProduct, R> onBook, Function<FashionProduct, R> onFashion) { public R visit(Product product) { return switch (product) { case ElectronicProduct e -> onElectronic.apply(e); case BookProduct b -> onBook.apply(b); case FashionProduct f -> onFashion.apply(f); }; } } Which makes an operation a value you can assemble on the fly: Java ProductVisitor<BigDecimal> vat = new ProductVisitor<>( e -> ..., b -> ..., f -> ...); BigDecimal amount = vat.visit(book); The Builder This design pattern belongs to the creational category, like the factory, but it solves a different problem. The factory hides which concrete type gets instantiated, while the Builder assembles a single, complex object step by step, separating its construction from its representation. It is the classic answer to the telescoping-constructor problem: an object with many parameters, among which some are required, most optional, whose constructor would otherwise explode into a combinatorial set of overloads. Our Product records have only three required fields, so they don't motivate a builder. We therefore introduce an Order: a customer order that aggregates the common products as line items and adds several optional attributes: a coupon code, a gift-wrap flag, and a free-text note. Whatever the style, the target is the same immutable value: Java public record Order( String customer, String currency, List<Product> items, Optional<String> coupon, boolean giftWrapped, Optional<String> note) { public Order { Objects.requireNonNull(customer, "Customer is null"); Objects.requireNonNull(currency, "Currency is null"); items = items == null ? List.of() : List.copyOf(items); coupon = coupon == null ? Optional.empty() : coupon; note = note == null ? Optional.empty() : note; } public BigDecimal subtotal() { ... } } The Object-Oriented Approach The figure below shows the class diagram of the object-oriented builder: The classic Gang of Four Builder is a mutable accumulator. The required arguments are captured up front; the optional ones are added through fluent calls that all return this, and build() freezes the accumulated state into the immutable Order: Java public final class OrderBuilder { private final String customer; private final String currency; private final List<Product> items = new ArrayList<>(); private String coupon; private boolean giftWrapped; private String note; public static OrderBuilder of(String customer, String currency) { ... } public OrderBuilder addItem(Product item) { items.add(item); return this; } public OrderBuilder coupon(String coupon) { this.coupon = coupon; return this; } public OrderBuilder giftWrap() { this.giftWrapped = true; return this; } public OrderBuilder note(String note) { this.note = note; return this; } public Order build() { return new Order(customer, currency, items, Optional.ofNullable(coupon), giftWrapped, Optional.ofNullable(note)); } } Building an order reads as a sentence, and you only mention the parts you actually need: Java Order order = OrderBuilder.of("Alice", "EUR") .addItem(book).addItem(phone) .coupon("SUMMER").giftWrap() .build(); The Functional Approach Look now at the class diagram of the functional style implementation: The functional counterpart keeps the same immutable Order target but drops the mutable accumulator. Each build step becomes a first-class UnaryOperator<Order> value, a pure function mapping one immutable Order to the next by returning a modified copy: Java public static UnaryOperator<Order> addItem(Product item) { return order -> new Order(order.customer(), order.currency(), Stream.concat(order.items().stream(), Stream.of(item)).toList(), order.coupon(), order.giftWrapped(), order.note()); } Because the steps are ordinary values, they are not called on a builder, but they are composed with andThen, exactly as the factory composed its factoryfunction and the visitor composed its operations: Java Function<Order, Order> config = addItem(book) .andThen(addItem(phone)) .andThen(coupon("SUMMER")) .andThen(giftWrap()); Order order = config.apply(OrderBuilder.empty("Alice", "EUR")); This is more than a stylistic variation. In the OOP version, a step is a method call that exists only for the duration of the chain. In the FP version, a step is a value that can be stored in a variable, passed to another method, kept in a list of steps and applied later, or reused the very same step twice: Java UnaryOperator<Order> addBook = addItem(book); Order order = addBook.andThen(addBook).apply(OrderBuilder.empty("Alice", "EUR")); The object-oriented Builder wraps a stateful object around the immutable target, while the functional one expresses construction as the composition of pure copy functions over it. "Turtles all the way down", and both land on the same Order. The Decorator This design pattern belongs to the structural category, and its purpose is to attach additional responsibilities to an object dynamically by wrapping it in another object that shares the same interface. It is the flexible alternative to subclassing for extending behavior: rather than a combinatorial explosion of DiscountedTaxedGiftWrappedProduct subclasses, you wrap a product in as many independent decorators as you need, and they stack. We reuse the same Product domain. Each decorator changes the price() and the description() while leaving everything else untouched. To keep the pattern visibly distinct from the visitor, whose rules varied per product type, the decorators here apply the same rule to every product: Discounted: 10% off the wrapped price.Taxed: adds 20% VAT to the wrapped price.GiftWrapped: adds a flat `5.00` wrapping fee. Because they stack, a 100.00 book decorated Discounted → Taxed→GiftWrapped goes 100.00 → 90.00 → 108.00 → 113.00, and its description reads "A book discounted, VAT incl., gift-wrapped." The Object-Oriented Approach The figure below shows the class diagram of the object-oriented decorator: The classic Gang of Four Decorator is an object that implements the component interface and holds a reference to another component, delegating the untouched operations and overriding the ones it enhances. An abstract ProductDecorator captures the delegation once: Java public abstract class ProductDecorator implements Product { protected final Product product; protected ProductDecorator(Product product) { this.product = Objects.requireNonNull(product, "Product is null"); } public String name() { return product.name(); } public String description() { return product.description(); } public BigDecimal price() { return product.price(); } public ProductType type() { return product.type(); } } Each concrete decorator then overrides only what it changes: Java public class Discounted extends ProductDecorator { private static final BigDecimal RATE = new BigDecimal("0.10"); public Discounted(Product product) { super(product); } public BigDecimal price() { return product.price().subtract(amount(product.price(), RATE)); } public String description() { return product.description() + " (discounted)"; } } Since a decorator is a Product, decorators wrap decorators, and the enhancements compose by nesting: Java Product wrapped = new GiftWrapped(new Taxed(new Discounted(new BaseProduct(book)))); BigDecimal price = wrapped.price(); // 113.00 The leaf being wrapped is a BaseProduct, a small record that adapts a shared common.Product into the decorator's own interface. This is necessary because common.Product is sealed and so, exactly like the object-oriented visitor, the decorator cannot make the common records implement its interface directly. The Functional Approach Look now at the class diagram of the functional style implementation: The functional counterpart of a decorator is simply a function which maps a product to an enhanced product and implemented as an UnaryOperator<Product>. Because the common records are immutable, "enhancing" one means rebuilding it through the ProductType factory, already seen at the very beginning, which is why the FP side reuses common directly with no adapter: Java public static final UnaryOperator<Product> DISCOUNTED = product -> product.type().newInstance(product.name(), product.description() + " (discounted)", product.price().subtract(amount(product.price(), "0.10"))); Being ordinary values, the decorations compose with andThen, exactly as the factory composed its factory function, the visitor composed its operations, and the builder composed its steps: Java UnaryOperator<Product> decorate = DISCOUNTED.andThen(TAXED).andThen(GIFT_WRAPPED); Product wrapped = decorate.apply(book); // price 113.00 And, just like the functional builder step, a decoration is a reusable first-class value. For example, the same discount could be applied twice: Java Product wrapped = DISCOUNTED.andThen(DISCOUNTED).apply(book); // 100 -> 90 -> 81 The object-oriented Decorator wraps the component in a stack of objects sharing its interface, while the functional one expresses the very same stacking as the composition of pure Product to Product functions. "Turtles all the way down", and both land on the same enhanced product. The Strategy This design pattern belongs to the behavioral category, and its purpose is to define a family of algorithms, encapsulate each one of them, and make them interchangeable, such that the algorithm may vary independently of the client using it. Where the decorator asked what else should happen to this object ?, the strategy asks which one of these algorithms should be applied ?. We keep the same Product domain and we compute a shipping cost for it. Three interchangeable algorithms are provided: Standard: a flat 4.99 fee.Express: 9.99 plus 2% of the product price.FreeOver: the familiar "free delivery over 50.00" commercial rule. It is parameterized by a price threshold and by the strategy to apply when the threshold isn't reached: should the product price be greater than or equal to the threshold, the shipping is free; otherwise, the product doesn't qualify, and the cost is the one computed by that other strategy. For our 100.00 book, the standard shipping costs 4.99 and the express one costs 11.99. As for the free-over one, with a threshold of 50.00 and a StandardShipping()strategy, the cost is 0.00, since 100.00 is above the threshold. Raising that same threshold to 150.00 falls back to the standard shipping and, hence, the cost is 4.99. Notice that, unlike the visitor, nothing here varies per product type: what varies is the algorithm, and it is the caller that picks it. The Object-Oriented Approach The figure below shows the class diagram of the object-oriented strategy: The classic Gang of Four Strategy declares an interface for the family of algorithms and one class per algorithm: Java public interface ShippingStrategy { BigDecimal cost(Product product); } public class ExpressShipping implements ShippingStrategy { private static final BigDecimal FEE = new BigDecimal("9.99"); private static final BigDecimal RATE = new BigDecimal("0.02"); public BigDecimal cost(Product product) { return FEE.add(product.price().multiply(RATE).setScale(2, RoundingMode.HALF_UP)); } } StandardShipping and ExpressShipping are stateless, their fees being constants. But an algorithm that needs to be parameterized has nowhere to keep its parameters other than instance fields and, hence, becomes a class with state. This is the case of FreeOverShipping, which holds both its threshold and the strategy to fall back to below it, every such pair defining a different algorithm: Java public class FreeOverShipping implements ShippingStrategy { private final BigDecimal threshold; private final ShippingStrategy otherwise; public FreeOverShipping(BigDecimal threshold, ShippingStrategy otherwise) { ... } public BigDecimal cost(Product product) { return product.price().compareTo(threshold) >= 0 ? FREE : otherwise.cost(product); } } Last but not least, the context is the object that uses the algorithm without knowing which one it is. It only holds a reference to the interface, which is what allows the algorithm to be replaced at runtime: Java ShippingCalculator calculator = new ShippingCalculator(new StandardShipping()); BigDecimal cost = calculator.cost(book); // 4.99 BigDecimal total = calculator.total(book); // 104.99 calculator.setStrategy(new ExpressShipping()); cost = calculator.cost(book); // 11.99 total = calculator.total(book); // 111.99 Contrary to the visitor and to the decorator, the strategy doesn't require anything at all from the elements it processes: no `accept` method and no shared component interface. Consequently, and this is the first time it happens on the object-oriented side, the module reuses the sealed common.Product directly, with neither its own hierarchy, nor any adapter. The Functional Approach Look now at the class diagram of the functional style implementation: Of all the patterns seen so far, this is the one where the functional answer is the most radical. The interface ShippingStrategy in the OO implementation declares one single method and holds no state, such that everything it tells us is a Product comes in, a BigDecimal comes out. In functional terms, it is nothing more than a Function<Product, BigDecimal> type. So each algorithm becomes a plain value of the function type, for example: Java public static final Function<Product, BigDecimal> EXPRESS = product -> EXPRESS_FEE.add(product.price().multiply(EXPRESS_RATE).setScale(2, RoundingMode.HALF_UP)); As opposed to the OO side, which required the FreeOverShipping class holding the threshold and the shipping strategy, the FP side captures them in a closure. So this class on the OO side becomes on the FP side a higher-order function, i.e. a function returning the strategy itself: Java public static Function<Product, BigDecimal> freeOver(BigDecimal threshold, Function<Product, BigDecimal> otherwise) { return product -> product.price().compareTo(threshold) >= 0 ? FREE : otherwise.apply(product); } The very same happens to ShippingCalculator, the context class on the OOP side. Its whole reason to exist was to hold a strategy in a field, such that its cost()and total() operations could delegate to it. But a context is just an operation parameterized by an algorithm and this, once again, is precisely a higher-order function. Hence, the ShippingCalculator.total() method becomes: Java public static Function<Product, BigDecimal> totalWith(Function<Product, BigDecimal> strategy) { return product -> product.price().add(strategy.apply(product)); } such that the following call on the OO side: Java ShippingCalculator calculator = new ShippingCalculator(new StandardShipping()); ... BigDecimal total = calculator.total(book); becomes on the FP side: Java BigDecimal total = totalWith(STANDARD).apply(book); There is no field to hold the strategy anymore and, consequently, no setStrategy()method either. Here the strategy is an argument which doesn't need to be stored in the context, just call the function with the right value. But the real advantage of the strategies as ordinary values is that they can be combined. Picking the cheapest of several shipping options requires yet another class on the OO side, while here it's a simple combinator: Java Function<Product, BigDecimal> best = cheapest(STANDARD, EXPRESS); // 4.99 And as usual, they compose with andThen, for example to apply a promotion to whatever cost has been computed: Java Function<Product, BigDecimal> promo = EXPRESS.andThen(cost -> cost.divide(TWO, 2, RoundingMode.HALF_UP)); // 6.00 The OO Strategy encapsulates each algorithm in a class implementing a common interface and injects the chosen one into a context object, while the functional one observes that such an interface describes nothing but a function type which the JDK already provides and, consequently, keeps only the algorithms themselves. "Turtles all the way down", and both compute the same cost. Project Structure The code is organized as a multi-module Maven project. The product domain lives in its own common module: a sealed Product interface, the three product records, and the ProductType enumerated which already carries the FP factory function seen above. Everything that can reuse that domain does: Plain Text oop-fp-design-patterns (parent POM) ├── common sealed Product, the records, ProductType(+factory) ├── factory (→ common) ProductFactory (OOP); the FP factory *is* common.ProductType ├── visitor (→ common) FP: operations over the common records (switch + lambda bundle) │ OOP: its own element hierarchy (see below) ├── builder (→ common) immutable Order over the common records; OOP: fluent │ OrderBuilder; FP: composed UnaryOperator<Order> steps ├── decorator (→ common) FP: composed UnaryOperator<Product> decorations over the │ common records; OOP: its own Product interface (see below) └── strategy (→ common) shipping algorithms over the common records; OOP: the ShippingStrategy hierarchy + context; FP: plain Function<Product, BigDecimal> values The FP factory, the FP visitor and the FP decorator all operate directly on the common records, so nothing is duplicated there, and the Strategy does so on both of its sides. The two exceptions are the object-oriented Visitor and the object-oriented Decorator. The Visitor needs an accept method on every element (double dispatch). The Decorator needs a non-sealed Product interface that its wrappers can implement. In both cases, common.Product is sealed and cannot be extended from another module, so each owns its own element/component types and reuses only the ProductType enumerated. The OOP decorator bridges back to common through a small BaseProduct adapter. This asymmetry is not accidental. The classic Visitor requires every element to expose an accept method, and the classic Decorator requires every component to share the wrappers' interface. Both couple the elements to the pattern's abstraction, so they cannot be the sealed records defined in common. The functional approach has no such coupling: it operates over the sealed type from the outside, pattern-matching for the visitor, rebuilding through the factory for the decorator, so the elements know nothing about the operations applied to them and, hence, can be the shared common records. The Strategy confirms the rule the other way around: it doesn't couple the elements to its abstraction either, only the client to it, and this is precisely why it is the only pattern here whose object-oriented implementation reuses `common` as freely as its functional one. The full code of these examples, including the associated unit tests, can be found here. Have a great summer, everyone!

By Nicolas Duminil DZone Core CORE
The Tectonic AI Platform: A Framework for Taming App Sprawl and Data Fragmentation
The Tectonic AI Platform: A Framework for Taming App Sprawl and Data Fragmentation

If you have spent any time inside a mid-to-large organization that has embraced AI-assisted development, you've probably seen the pattern already. Teams move fast. New apps get spun up in days. Business units that used to wait months for IT now have working tools in a week. On the surface, it looks like a win. But look a little deeper, and a different picture starts to emerge. I've seen this happen firsthand: within twelve months of an organization adopting AI-assisted development, the internal app count can double, sometimes triple. And with every new app comes a fresh copy of the customer table, a slightly different definition of what a "transaction" means, and another team that has no idea what the team next door already built. The result is two compounding problems, and most organizations are treating them as if they're separate issues when they share the same root cause. The Two Problems Nobody Is Connecting There are two main problems that are impacting companies developing and deploying AI apps. They are: App Sprawl: Dozens of small applications accumulate. Each needs maintenance, security patches, dependency updates, and an owner. Most were built fast and designed by no one; they were generated. I have watched engineering teams burn entire sprints just cataloging what exists, let alone maintaining it. The long tail of unmaintained micro-apps quietly becomes an engineering liability. Data Scattering: The same business entities, customers, products, orders, and employees are defined slightly differently in every application. No canonical version exists anywhere. The same customer record lives in six places with six slightly different schemas. Reporting turns out to be like being an archaeologist! Integrations become fragile. Resuming any reconstruction means untangling a whole lot of divergent assumptions over the course of months. Most organizations look at them as individual issues: App governance is one, and data warehouse is the other. They come late and cure both the symptoms and not the cause. The actual root cause? No shared platform layer makes it structurally easy to build new applications without duplicating data and easy to share capabilities without reinventing them. Every new app starts from scratch. It creates its own database, its own auth, its own version of "what a customer is." The AI assistant helping build it has no way to know what already exists. So it builds freshness every time. The problem isn't that developers are building too much. The problem is that nothing they build connects to a common foundation. Introducing the Tectonic AI Platform The Tectonic AI Platform has been the architecture I've been working on that's actually a response to this. The governing idea is borrowed from geology: just as tectonic plates form the stable foundation beneath the dynamic surface of the earth, a Tectonic Platform provides a stable, canonical data and service layer beneath the fast-moving applications built on top of it. Applications are surface features fast to build, easy to replace, and expendable. The plate beneath them is the source of truth. It doesn't care what sits on top. It endures. This is not a product you install. It is an architectural posture, a set of structural decisions that organizations adopt before the sprawl begins or use to bring order after it already has. One important distinction worth making upfront: this is not a data warehouse. A warehouse is downstream and read-only. It doesn't stop three apps from each maintaining their own operational definition of a customer; it just lets you query all three versions in one place. The Tectonic plate is operational and live. It sits in the application layer, not below it. Apps read and write through it. It is the authoritative version, not a copy of one. The Four Pillars The framework is organized in this way. Each pillar addresses a specific failure mode that I've seen emerge when organizations skip the foundation. Pillar 1: Canonical Data Plates Shared, versioned data domains are owned by the platform, not by any single application. They include customers, products, transactions, and employees. These live on the plate. Applications interact with them through defined contracts (APIs), never by owning the underlying data store. Any app can read from the plate. Writing to it requires going through the contract. That's the word "owned by the platform" that is to be taken into account. I've seen people go to such trouble as trying to choose one app as the system of record to solve this problem. But that is no good — it would move ownership depending on how many people are on the roster. The plate does not belong to anyone; it is only legal to host the platform. Pillar 2: App Scaffolding Layer A generator framework that provisions new applications pre-wired to the plate layer from day one is also needed. When a developer or an AI assistant spins up a new app, it inherits auth, logging, observability, and data contracts automatically. The app starts connected, not isolated. Vibe coding stays fast. The structure comes for free. This is the foundation upon which the entire framework is designed to be interoperable with AI-assisted development. You aren't stopping anybody; you are just ensuring that the thing that they build into something also plugs in. Pillar 3: Capability Registry Organizations then need a discoverable catalog of everything that already exists, including APIs, workflows, AI models, reports, and integrations. Before building anything, developers (and AI coding assistants) query the registry first. Duplication becomes visible before it happens. "Does a customer lookup API already exist?" becomes a question with an answer. This is actually one of the most powerful pillars that are easy to acquire in practice. The overduplication is a mistake because people did not realize that it already existed. This is where the Register comes in. It also provides AI assistants with a surface to query before generating new code, changing the default from "build fresh" to "reuse first." Pillar 4: Governance at the Seam Rules and reviews live at the boundary between apps and plates. They are not inside individual apps. A new app can be built freely and quickly. What is allowed to be written on the plate is governed. This separates the fast surface (application layer) from the stable core (plate layer). Speed doesn't get sacrificed. Data integrity doesn't either. I want to make it clear what this pillar is NOT: it's not a committee, it's not a "ticket queue," and it's not a "review board." Governance at the seam should be automated wherever possible, including contract validation, schema versioning checks, and write permission enforcement. It's all about guardrails, not gatekeeping. What This Prevents Five Years From Now Without a Tectonic layer, here's what the organization typically looks like five years into an AI-assisted development culture: A long tail of unmaintained micro-apps, each with its own auth, its own schema, its own error handlingEngineers are spending more time stitching data together than building new capabilitiesAn AI-assisted development culture that has paradoxically made the codebase harder to understand because the surface area has exploded without any unifying structureRebuilding the same core capabilities repeatedly across teams that never knew the others existed A Tectonic layer is now in place, and every new application, no matter how quickly it is created, takes on its structure. The transformative era of vibe coding keeps on rolling. Technical "debt" is not compounded. Speed Without Structure Is Just Faster Entropy The Tectonic AI Platform is not anti-AI and not anti-speed. It is the infrastructure argument for why AI-assisted development can scale inside an organization without eventually collapsing under its own weight. The organizations that define their plates early, their canonical data domains, their shared capability contracts, and their scaffolding standards will find in a few years that they have a large and growing estate of AI-generated applications that actually work together. Those who don't will have a different, large, and growing estate. And a much harder problem to fix. The plate layer is what makes the speed sustainable. Define it early, or spend years paying for not having done so.

By Saravanan Muniraj
How to Break Up Swift Concurrency
How to Break Up Swift Concurrency

Need to perform asynchronous operations and support multitasking in your app? Async/await is at your service — simple and elegant. The cooperative thread pool efficiently switches threads between tasks, while the compiler ensures thread safety at the type level. You can even seamlessly bridge older parts of your codebase written in GCD! But then, for some reason, your app starts hanging in production… Below, we will explore specific examples (complete with diagrams) of how not to mix async/await code with DispatchQueue (the same rules apply to other blocking primitives). The Root of the Problem The system doesn’t allocate a dedicated thread for every Task. Instead, tasks are executed on a cooperative thread pool, where the number of available threads never exceeds the number of active CPU cores. Therefore, you can cheaply spawn thousands of tasks — they are merely small allocations on the heap, not separate threads. However, a blocking GCD call or an infinite task (a loop) is not a suspension point; they occupy the thread and do not return it to the pool. The more of these tasks there are, the higher the chance of depleting the pool. Each of the methods below leads to this situation in its own way. Method #1. Saturating the Pool With Blocking Tasks The simplest way is to occupy every thread in the pool with a task that blocks it until its execution is complete. An example using DispatchQueue.sync: Swift // The pool size is 2. We launch 2 blocking tasks, each on its own queue. for i in 0..<2 { Task { DispatchQueue(label: "blocking-\(i)").sync { // blocks the thread // some heavy work } print("done") } } Task { print("See you later...") } // stuck in the queue, won't execute anytime soon The task inside sync does not suspend. The thread from the pool waits until the block finishes executing on the queue. If you do this simultaneously on every thread in the pool, its throughput will drop to zero. The pool recovers after the blocks complete. But while they are executing, nothing that lives on it makes progress: non-isolated async functions, regular actors, TaskGroup (@MainActor and GCD queues continue to work in the meantime — the main actor has its own executor on the main thread, and GCD has its own pool). The heavier the task — a synchronous network request, heavy computation, file I/O — the longer the stall. How can this slip through tests? If you only test on powerful devices. If, say, 4 blocking tasks occur simultaneously at runtime, the code might run normally on 8 cores, but then fail on a 2-core CI runner or a low-end device. Additionally Pool exhaustion due to blocking calls is discussed in the Swift Forums thread Deadlock When Using DispatchQueue from Swift Task, where a reader-writer subsystem managed by a TaskGroup deadlocks as soon as a sufficient number of tasks simultaneously block their threads in the pool. The Problem With the Vision Framework A blocking call can be inside third-party code, and you won’t see it in your own. The Swift Forums thread Cooperative pool deadlock when calling into an opaque subsystem describes such a case: a seemingly synchronous Apple API (VNImageRequestHandler.perform from Vision) internally drops down into GCD and blocks the calling thread. Just a few concurrent tasks calling it are enough to exhaust the cooperative pool and hang the entire application. Method #2. Creating a Deadlock Between Queues Thread starvation is temporary if the blocking call eventually finishes. To make it permanent, you need to arrange it so that two blocked threads wait for each other. Swift let queueA = DispatchQueue(label: "A") let queueB = DispatchQueue(label: "B") Task { queueA.sync { // holds the pool thread on A... queueB.sync { } // ...then waits for B } } Task { queueB.sync { // holds the pool thread on B... queueA.sync { } // ...then waits for A → circular wait } } The queueA block won't complete until queueB is freed, and the queueB block won't complete until queueA is freed. Important: This is not a guaranteed deadlock. It only happens if both outer sync calls manage to capture their queues before the inner sync calls execute. If the first task completely finishes before the second one starts, nothing will happen. This can lead to intermittent (flaky) bugs. Method #3. Creating a Deadlock on a Single Queue Variant 1. Two Nested sync Calls A familiar situation: Swift let queue = DispatchQueue(label: "serial") Task { queue.sync { // blocks the cooperative thread // ...work... queue.sync { } // sync on the same serial queue } } In practice, this will more likely result in a crash rather than a hang. libdispatch recognizes the simple case — the thread already owns the queue and calls sync on it again — and intentionally crashes the application with EXC_BAD_INSTRUCTION and the message BUG IN CLIENT OF LIBDISPATCH: dispatch_sync called on queue already owned by current thread. This applies to a serial queue. A nested sync on a concurrent queue will not cause a deadlock, but it will still hold the pool thread. sync deadlocks between queues and on a single queue are well-known GCD "pitfalls"; it is easy to fall into them in a cooperative pool of limited size. Variant 2. A Hidden Reentrant sync and a Single Queue for Everything The blocking call can be hidden behind an innocent helper function. For example, in a seemingly safe synchronous accessor like this: Swift let queue = DispatchQueue(label: "store") func currentUser() -> User { // used throughout the code queue.sync { _user } // fine — as long as you are not on `queue` } And now someone somewhere starts work on the same queue and calls this helper from within: Swift Task { queue.sync { // now executing ON `queue` let user = currentUser() // currentUser() calls queue.sync again apply(user) // the same serial queue → crash } } Each call looks normal on its own. The problem only arises when they are combined, and its two halves might reside at opposite ends of the codebase. As a result, the application crashes with the same libdispatch message as in Variant 1, but the stack trace doesn't immediately reveal that two "normal" halves of code from different files are to blame. Method #4. Not Keeping Track of @MainActor The main thread is not part of the cooperative pool; @MainActor has its own executor on the main thread. But the scheduling model is the same — cooperative — and a blocking sync breaks it in exactly the same way: Swift @MainActor func onTap() { let worker = DispatchQueue(label: "load") worker.sync { // blocks the main thread, the UI freezes let data = loadDataSync() DispatchQueue.main.sync { // worker is now waiting for main... render(data) // ...but main is blocked above → deadlock } } } Blocking the main thread stops rendering, gesture processing, and run loop events. The user sees a frozen screen, and the watchdog might kill the application. Method #5. Not Suspending Heavy Synchronous Tasks Without GCD or any primitives. A task performing long synchronous work between await points also does not yield its thread back: Swift Task { while true { heavySynchronousWork() // never reaches an await } // holds its thread forever } In a cooperative pool, the runtime can only reassign a thread at a suspension point. No await means no yielding. From the pool's perspective, a tight CPU loop without an await is indistinguishable from a blocking call; it just does useful work while 'starving' everyone else. A possible solution is to break the long-running work into chunks with await Task.yield() between them: Swift Task { while !Task.isCancelled { heavySynchronousWork() await Task.yield() } } Apple’s documentation for Task.yield() describes it as suspending the current task to allow other tasks to execute. But this is not an ideal solution, because between yield points, the work still occupies a pool thread. There is another option: moving the heavy work out of the pool entirely, for example, via GCD + continuation or a separate executor. How Not to Break Swift Concurrency Do not call long-running tasks under blocking primitives or queue.sync inside a Task. Short critical sections under a fast lock (os_unfair_lock, NSLock, an instantaneous queue.sync around a field read) are acceptable: the thread holding the lock will perform the work itself and release it immediately.Call callback APIs using continuations. To turn a GCD API with a completion handler into an async function, wrap it in withCheckedContinuation (or withCheckedThrowingContinuation when an error is possible). The continuation suspends the task and resumes it from the callback without blocking the thread.Keep blocking sync calls from the same queue in one place. If a public function blocks the thread, indicate this explicitly (via its signature or a comment) or use async.Watch out for calls within @MainActor methods. Do not call heavy tasks under sync on the main thread, with the exception of a short sync for the sake of an atomic read. Launch heavy work in a separate Task or queue and update the UI asynchronously.Use suspension in heavy loops. Insert await Task.yield() so that a long (or infinite) task does not hijack a pool thread for itself, or move the work out of the cooperative pool.Test on low-end devices and under load. In an environment with 1–2 cores or on a pool saturated with concurrent tasks.

By Pavel Andreev
FastAPI + Django in Production: Lessons From a Hybrid Stack
FastAPI + Django in Production: Lessons From a Hybrid Stack

Picture the scene: One of the services in your backend is a mature Django app that no one has the resources, time, or, frankly, the will to rewrite. The ORM, the admin panel, and the broader ecosystem all earn their keep. But you’re looking for the best way to describe your API, and FastAPI catches your eye. It looks like a great fit: native typing, pydantic-based validation, OpenAPI out of the box, and of course the support for async endpoints. That's the situation our team found itself in - we decided to use both frameworks and take from each what suited us best. Not everything went smoothly — this post is what we built, what broke afterward, and what we learned. The First Win So we wired it up, and it works. FastAPI runs as the ASGI application, and the existing Django app plugs into it. Python # asgi.py import os os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings") from django.core.asgi import get_asgi_application from fastapi import FastAPI app = FastAPI() django_app = get_asgi_application() app.mount("/legacy", django_app) Great! Now: Both Django and FastAPI endpoints live side by side, with no pressure to refactor everything in a single day — that was important for us.In the new parts of the app, Django steps back into a single role: communicating with the database through its models.Endpoints can be either sync or async. That was the win. But there was the other side also. Pitfall 1: Async Endpoints Started Running One at a Time When you reach out to external services, chances are you also want to enrich the request with something from your database, or save the result back to it (we did). Here's a tiny example: A single async handler that fetches data about Order from the database (we use Postgres) and forwards it to an external payment provider. Python from asgiref.sync import sync_to_async from fastapi import FastAPI app = FastAPI() @app.post("/orders/{order_id}/dispatch") async def dispatch_order(order_id: int) -> OrderDTO: order = await sync_to_async(get_order)(order_id) # fetch from DB await client.send_order(order) # call external service return order # code that uses a Django model def get_order(order_id: int) -> OrderDTO: order = Order.objects.get(id=order_id) return OrderDTO(id=order.id, amount=order.amount) Inside an async function, you can’t call the Django ORM synchronously. The documented approach is sync_to_async, which moves the synchronous call to a separate thread so it doesn’t block the event loop. Now let's see what happens under concurrent load. Drop a three-second sleep into get_order: Python from django.db import connection def get_order(order_id: int) -> OrderDTO: order = Order.objects.get(id=order_id) with connection.cursor() as cursor: cursor.execute("SELECT pg_sleep(3);") return OrderDTO(id=order.id, amount=order.amount) And fire three requests in parallel: Shell URL="http://localhost:8000/orders/1/dispatch" curl -s -o /dev/null -X POST -w "%{time_total}s\n" "$URL" & curl -s -o /dev/null -X POST -w "%{time_total}s\n" "$URL" & curl -s -o /dev/null -X POST -w "%{time_total}s\n" "$URL" >> 3.012s >> 6.024s >> 9.037s We expected ~3 seconds and got nine. The handlers ran one after another, not concurrently. And if you log the thread and database connection IDs from inside get_order, all three requests print the same values. Why? By default sync_to_async(get_order) runs with thread_sensitive=True, which means the function runs in the same thread as all other thread_sensitive functions. A standalone Django ASGI app does extra work here: it opens a fresh context per request, so requests run in parallel. The benchmark suggests that in our setup FastAPI doesn't: all three sync_to_async calls land on the same thread and line up one behind another. The event loop itself stays free, by the way: a purely async route keeps responding while the three /dispatch requests wait in that queue. But three async handlers with ORM calls queue up on the same thread, sharing the same connection. For a moment we hoped Order.objects.aget(...) or other Django async ORM helpers would save us here. They won't: for now under the hood they call the same sync_to_async. Can we just flip to sync_to_async(..., thread_sensitive=False)? Probably not - it is not a safe default. Django carries a lot of per-request state in thread-locals: the current DB connection, transaction.atomic(), etc. The Django docs say: "a lot of existing Django code assumes it all runs in the same thread." What to Do About It No silver bullet, but two approaches hold up: Split handlers by what they touch. Reserve async def for endpoints that genuinely don't touch the ORM — async-native HTTP calls, cache reads, etc. For ORM-bound endpoints, declare them as plain sync routes. FastAPI runs sync routes on its thread pool, so they actually run in parallel, and each thread gets its own Django connection. As long as these endpoints don't make many slow external calls, this can work.Move the work out of the handler entirely. If your project already runs with a message broker, the possible answer to "external API + DB write inside a handler" is to stop doing it inside a handler at all. Drop an event on the bus, let consumers handle the side effects, return immediately. The catch: this only makes sense when an event-driven flow already fits your system — because it is, of course, no small refactor. Pitfall 2: Tests That Can't See Their Own Data Now let's write a test for get_order — a sync endpoint that reads an order from the DB. The test runs with pytest-django: we create an order in the database and call the handler. Python # app.py import pytest from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient app = FastAPI() @app.get("/orders/{order_id}") def get_order(order_id: int) -> OrderDTO: try: order = Order.objects.get(id=order_id) except Order.DoesNotExist: raise HTTPException(status_code=404) return OrderDTO(id=order.id, amount=order.amount) @pytest.mark.django_db def test_get_order(): Order.objects.create(id=1) response = TestClient(app).get("/orders/1") assert response.status_code == 200 # and we'll have 404 You get 404 Not Found. The handler ran, looked at the database, and the order was nowhere to be found. Four facts conspire here: Pytest runs your test's data setup in one thread; when the FastAPI test client calls the endpoint, the handler runs in another.pytest-django wraps every test in an open transaction and rolls it back at the end. That's how the suite stays fast and isolated. The transaction lives on a single database connection.Django opens a database connection per thread.Postgres defaults to READ COMMITTED isolation: one connection cannot see another connection's uncommitted writes. So: the test body runs in the pytest thread. Its Order.objects.create(...) uses connection 1, inside pytest-django's open transaction. When TestClient hits the endpoint, FastAPI dispatches the handler to a worker thread from its thread pool, on another thread with its own connection 2. Connection 2 looks at the database and sees no order, because connection 1 hasn't committed, so connection 1's write is effectively invisible to everyone else. Again — What to Do? Test in layers. Unit-test the endpoint contract with the ORM mocked - those tests don't cross thread or connection boundaries, so the visibility problem simply can't appear. Test business logic and data access in their own tests, without going through TestClient. For cases when the full end-to-end test is still needed - the commonly suggested fix is @pytest.mark.django_db(transaction=True). This switches the test to a mode where writes actually commit, so other connections can see them. But it has its cost: pytest-django now does a database flush after every test, and the suite gets noticeably slower. On a large suite, for us "noticeably" meant minutes - too much on every run, so we use it only for exceptional cases. The Recap FastAPI brings obvious wins — OpenAPI docs, clean endpoint code, typing all the way through; Django gives you a greatly tested ORM and admin. Putting them in the same process gives us both — and a thread-and-connection model that doesn't behave the way we'd expect. Budget for the architecture work before you budget for the migration. Was it worth it? Yes — we got the clean, typed API we were after, and we kept Django's ORM instead of porting the whole data layer to another framework. Would we do it again? Not sure. The trade-offs of this integration may outweigh its benefits for us, so other combinations might be a better fit. If you’ve run into the same solution and found an approach with better trade-offs, please share; the comments are open. Reproduce it yourself. An example with a benchmark and failing tests is in https://github.com/evchibisova/fastapi-over-django-test.

By Evgeniia Chibisova
Deploying a Spring Boot Microservice on AWS Fargate: Lessons From the Outage That Forced Me to Get It Right
Deploying a Spring Boot Microservice on AWS Fargate: Lessons From the Outage That Forced Me to Get It Right

My first attempt to deploy a Spring Boot microservice on AWS Fargate didn’t fail loudly. It failed quietly — in a loop. ECS kept launching tasks, the Application Load Balancer kept marking them unhealthy, and the service never stabilized. The logs looked fine, the container looked fine, but the ALB replaced every task within seconds. The root cause was painfully simple: Spring Boot needed 45 seconds to start, and my ALB health‑check timeout was 5 seconds. The tasks never had a chance. That night changed how I build and deploy microservices. It forced me to rethink startup behavior, JVM sizing, networking, task definitions, and the entire CI/CD pipeline. This article is the guide I wish I had before that incident — a practitioner’s walkthrough of deploying a production‑ready Spring Boot service on AWS Fargate, with real artifacts and the details that matter when things go wrong. The Architecture That Finally Worked Once the health‑check issue was fixed, the architecture settled into a predictable, cloud‑native flow: Developers push code to GitHubGitHub Actions builds the JARDocker image is built and pushed to Amazon ECRECS service runs AWS Fargate tasksTraffic enters through an Application Load BalancerTasks run in private subnetsConfiguration comes from Parameter Store and Secrets ManagerLogs and metrics flow to CloudWatch It’s the standard modern microservice pipeline — but the difference between “standard” and “production‑ready” is in the details. The Spring Boot Service The microservice itself was simple — a REST API with a few endpoints. The real complexity wasn’t the controller logic; it was everything around it: startup time, health checks, configuration management, and container behavior under load. A Dockerfile Built for Production My first Dockerfile looked like the one many tutorials start with: a single‑stage build running as root with no JVM tuning. It worked locally but failed under real load. Fargate tasks with default JVM heap sizing inside a 2GB container are a classic OOM story. Here’s the hardened version that finally stabilized deployments: Dockerfile FROM eclipse-temurin:21-jre # Create non-root user RUN useradd -u 1001 springuser WORKDIR /app # Layer extraction for faster builds COPY target/*.jar app.jar # JVM tuning for Fargate ENV JAVA_OPTS="\ -XX:MaxRAMPercentage=75 \ -XX:+UseContainerSupport \ -XX:+ExitOnOutOfMemoryError \ " USER springuser ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] This eliminated the OOMKilled events I saw on 2GB tasks and made startup time predictable. Pushing to Amazon ECR With Real Commands The first time I wrote down my ECR commands, they were placeholders. In production, they need to be exact: C aws ecr get-login-password --region us-east-1 \ | docker login --username AWS --password-stdin <ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com docker build -t employee-service:1.0.3 . docker tag employee-service:1.0.3 \ <ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/employee-service:1.0.3 docker push \ <ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/employee-service:1.0.3 Immutable semantic version tags make rollbacks predictable and prevent “latest‑tag roulette.” The ECS Task Definition That Actually Runs in Production A real Fargate deployment lives or dies by its task definition. Here’s the JSON I use today — including secrets pulled from Parameter Store and Secrets Manager: JSON { "family": "employee-service", "networkMode": "awsvpc", "requiresCompatibilities": ["FARGATE"], "cpu": "512", "memory": "1024", "executionRoleArn": "arn:aws:iam::<ACCOUNT_ID>:role/ecsTaskExecutionRole", "taskRoleArn": "arn:aws:iam::<ACCOUNT_ID>:role/employeeServiceRole", "containerDefinitions": [ { "name": "employee-service", "image": "<ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/employee-service:1.0.3", "portMappings": [ { "containerPort": 8080, "protocol": "tcp" } ], "secrets": [ { "name": "DB_PASSWORD", "valueFrom": "arn:aws:ssm:us-east-1:<ACCOUNT_ID>:parameter/db/password" }, { "name": "API_KEY", "valueFrom": "arn:aws:secretsmanager:us-east-1:<ACCOUNT_ID>:secret:thirdparty/api" } ], "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-group": "/ecs/employee-service", "awslogs-region": "us-east-1", "awslogs-stream-prefix": "ecs" } } } ] } The ALB Health Check That Stopped the Outage My outage happened because the ALB was impatient. Here’s the configuration that finally stabilized deployments: settingvalue Path /actuator/health Interval 20 seconds Timeout 10 seconds Healthy threshold 3 Unhealthy threshold 3 Spring Boot startup time + ALB patience = stable deployments. Why Fargate Tasks Belong in Private Subnets Early on, I deployed tasks in public subnets because it felt simpler. It wasn’t. Public IPs meant the containers were directly reachable from the internet — port scans, bot traffic, and noisy logs. Moving tasks to private subnets solved several problems at once: Reduced Attack Surface No public IPs. No direct inbound traffic. Only the ALB can reach the tasks. A Single Secure Entry Point The ALB handles TLS termination, redirects HTTP→HTTPS, performs health checks, and integrates with WAF. Clients never bypass it. Cleaner Security Groups ALB SG: inbound 443 from the internetTask SG: inbound only from ALB SG Nothing else touches the containers. Compliance Alignment PCI, SOC 2, HIPAA — all prefer minimizing public exposure. Controlled Outbound Access Tasks use a NAT Gateway for outbound calls (updates, third‑party APIs) without exposing themselves. Better Scalability ALB target groups automatically track tasks across AZs as ECS scales. The architecture becomes simple and predictable: Internet → ALB (public subnets) → Fargate tasks (private subnets) It’s quieter, safer, and easier to operate. The GitHub Actions Workflow That Deploys Automatically Here’s the pipeline that builds, tests, pushes, and deploys the service: YAML name: Deploy to Fargate on: push: branches: ["main"] jobs: build-deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up JDK uses: actions/setup-java@v4 with: java-version: "21" - name: Build JAR run: mvn -B clean package - name: Login to ECR uses: aws-actions/amazon-ecr-login@v2 - name: Build and Push Image run: | docker build -t employee-service:1.0.3 . docker tag employee-service:1.0.3 ${{ env.ECR_REGISTRY }/employee-service:1.0.3 docker push ${{ env.ECR_REGISTRY }/employee-service:1.0.3 - name: Deploy ECS Service uses: aws-actions/amazon-ecs-deploy-task-definition@v2 with: task-definition: ecs-task.json service: employee-service cluster: prod-cluster Auto Scaling With Real Target Tracking JSON Target tracking is the simplest and most reliable scaling strategy for Fargate: JSON { "TargetValue": 50.0, "PredefinedMetricSpecification": { "PredefinedMetricType": "ECSServiceAverageCPUUtilization" }, "ScaleOutCooldown": 30, "ScaleInCooldown": 60 } I use 50% as the target because it balances cost and responsiveness. What I Learned Every failure taught me something: ALB timeouts taught me to respect startup timeOOMKilled tasks taught me to tune the JVMPublic subnets taught me to isolate workloadsManual deployments taught me to automate everything AWS Fargate really does deliver on its promise — no servers to manage, automatic scaling, and clean integration with ECS — but only after you learn the hard parts. If you’re deploying Spring Boot on Fargate, I hope you learn those lessons from this article instead of from your own outage.

By Vishal Rameshchandra Shah

Monthly Top Frameworks Experts

expert thumbnail

Justin Albano

Software Engineer,
IBM

I am devoted to continuously learning and improving as a software developer and sharing my experience with others in order to improve their expertise. I am also dedicated to personal and professional growth through diligent studying, discipline, and meaningful professional relationships. When not writing, I can be found playing hockey, practicing Brazilian Jiu-jitsu, watching the NJ Devils, reading, writing, or drawing. ~II Timothy 1:7~ Twitter: @justinmalbano

The Latest Frameworks Topics

article thumbnail
Pure Headless vs Hybrid Headless CMS: A Practical Decision Framework
Pure headless favors developer control; hybrid headless gives editors more flexibility. The right choice depends on how much content work requires engineering.
August 26, 2026
by Alex Vakulov DZone Core CORE
· 1,441 Views
article thumbnail
Understanding RabbitMQ Exchange Types in Spring Boot
This blog delves into various RabbitMQ exchange types used within a Spring Boot application, highlighting examples and configurations.
August 26, 2026
by Gunter Rotsaert DZone Core CORE
· 1,622 Views
article thumbnail
Containerizing Spark and Lakehouse Development with Docker
Use Docker to create a local lakehouse environment that mirrors production, while improving data engineering workflows, Spark testing, and CI reliability.
August 25, 2026
by Aniket Abhishek Soni
· 1,874 Views · 1 Like
article thumbnail
Demystifying Thread Hopping With Swift 6.2
Swift 6.2 fixes unexpected thread hopping in async code with Approachable Concurrency. This article explains the new execution model.
August 25, 2026
by Nikita Vasilev
· 958 Views · 1 Like
article thumbnail
Cutting AI Token Costs With MgntUtils Stack Trace Filtering
Learn how AI can reduce log volume and token costs by compressing stack traces while preserving the details needed for effective debugging and analysis.
August 24, 2026
by Michael Gantman
· 1,208 Views · 1 Like
article thumbnail
Stop Hand-Rolling Chat UIs: Streaming LLM Tokens Into React Native Without the Jank
In a React Native chat, the keyboard and a streaming, resizing list fight over the scroll position and cause jank. KeyboardChatScrollView fixes it.
August 20, 2026
by Tammo Ronke
· 1,425 Views
article thumbnail
Benchmark LangGraph, Strands, OpenAI Agents, and Google ADK on the Same Agent Graph
An experiment ranks them on latency and tokens with an LLM-as-a-judge quality guardrail; then the same flag promotes the winner to production with no redeploy.
August 13, 2026
by Scarlett Attensil
· 1,565 Views · 1 Like
article thumbnail
A Framework-Agnostic Approach to SSR for Microfrontends
Framework-agnostic SSR for independently deployed microfrontends — without a shared build, central orchestrator, or framework lock-in.
August 11, 2026
by Vitaly Zheltko
· 1,407 Views · 1 Like
article thumbnail
A Zero-Trust Implementation Framework for Cloud Migrations: Lessons From Enterprise Deployments
A zero-trust framework for cloud migrations, grounded in real enterprise deployment lessons. Perimeter security doesn't hold up once workloads move to the cloud.
August 7, 2026
by Srinivasarao Thumala
· 1,465 Views
article thumbnail
TensorFlow vs PyTorch: The Real Difference Isn’t Accuracy
A direct CNN benchmark on CIFAR-10 shows TensorFlow and PyTorch achieve identical accuracy (~68%). Choose TensorFlow for production and PyTorch for flexibility.
August 5, 2026
by Rakshath Naik
· 1,364 Views
article thumbnail
Rethinking Java Design Patterns: From OOP to FP
This article aims to adopt a more systematic and practical approach to combining Java object-oriented principles in a functional style.
August 4, 2026
by Nicolas Duminil DZone Core CORE
· 5,938 Views · 8 Likes
article thumbnail
The Tectonic AI Platform: A Framework for Taming App Sprawl and Data Fragmentation
Vibe coding and AI-driven development often lead to application sprawl and data fragmentation. Using a Tectonic AI Platform framework can help.
August 3, 2026
by Saravanan Muniraj
· 1,314 Views · 2 Likes
article thumbnail
How to Break Up Swift Concurrency
A technical deep dive into how blocking GCD calls and hidden deadlocks exhaust the Swift cooperative pool. Learn how to write safe, deadlock-free async code.
August 3, 2026
by Pavel Andreev
· 795 Views
article thumbnail
FastAPI + Django in Production: Lessons From a Hybrid Stack
Learn how to combine Django and FastAPI, including async ORM pitfalls, thread and database connection issues, testing challenges, and practical solutions.
July 31, 2026
by Evgeniia Chibisova
· 1,353 Views · 3 Likes
article thumbnail
Deploying a Spring Boot Microservice on AWS Fargate: Lessons From the Outage That Forced Me to Get It Right
Deploy a production-ready Spring Boot microservice on AWS Fargate with Docker, ECS, ALB health checks, private subnets, secrets, CI/CD, and autoscaling.
July 31, 2026
by Vishal Rameshchandra Shah
· 2,436 Views · 4 Likes
article thumbnail
Spark Performance Deep Dive on Databricks: Shuffle Tuning, Skew Handling, and Z-Ordering With Delta Lake + Unity Catalog
Learn Spark performance tuning on Databricks with shuffle optimization, skew handling, AQE, broadcast joins, and Delta Lake Z-Ordering best practices.
July 31, 2026
by Jubin Soni, FBCS DZone Core CORE
· 1,422 Views
article thumbnail
Retrieval Augmented Generation With Spring AI 2.0, Claude, and PGvector
Build a RAG service with Spring AI 2.0, Claude, and PGvector that answers questions from your own documents with a single API key.
July 31, 2026
by Murat Balkan DZone Core CORE
· 2,427 Views · 2 Likes
article thumbnail
This One Spring Data JPA Pattern Cleaned Up to 3 Years of Repository Debt
Stop adding repository methods every time a filter changes. JPA Specifications let you compose queries cleanly at runtime.
July 29, 2026
by Ramesh Bellamkonda
· 2,666 Views · 1 Like
article thumbnail
Designing Secure REST APIs With Spring Boot
Learn how to secure Spring Boot REST APIs with JWT validation, method-level authorization, input validation, rate limiting, CORS, secure logging, and more.
July 27, 2026
by Srivenkata Gantikota
· 2,970 Views · 1 Like
article thumbnail
Designing a Page Object Model + TestNG Hybrid Framework: Patterns That Actually Scale
Basic Page Object Model doesn't scale. Here are the four real-world patterns needed to maintain a 2,400-test Selenium suite.
July 27, 2026
by Rajasekhar sunkara
· 1,348 Views
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • ...
  • Next
  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook
×