Commit f809b1b6d5 for bind

commit f809b1b6d524f503462891815088e85474fb4c42
Author: Štěpán Balážik <stepan@isc.org>
Date:   Tue Sep 8 19:34:52 2026 +0200

    Replace DomainHandler with a matcher

    DomainHandler existed only to match a query below one of a list of
    domains, remembering which; its users now declare `Domain(...)`
    themselves, and the matcher keeps the most specific domain matched
    in `matched_domain` for the handler that needs it.

    Assisted-by: Claude:claude-fable-5

diff --git a/bin/tests/system/README.md b/bin/tests/system/README.md
index d803b33400..ad0263dc03 100644
--- a/bin/tests/system/README.md
+++ b/bin/tests/system/README.md
@@ -441,16 +441,10 @@ Most importantly, avoid the temptation to define all DNS responses that a given
 easier to follow for static DNS data.  Splitting up static DNS data and custom
 behavior also makes it easier to follow the idea behind each test.

-The most commonly subclassed handler classes are (ordered by descending
-specificity):
-
-  - `QnameQtypeHandler`
-  - `QnameHandler`
-  - `DomainHandler`
-
-These handler classes require certain properties (e.g. `qnames`, `qtypes`,
-`domains`) to be defined by their subclasses.  These properties define the set
-of queries that a given handler should be used for.  Please see
+A handler declares the queries it handles in its `matcher`, built from the
+matchers in `isctest/asyncserver/matchers.py` (`Qname`, `Qtype`, `Domain`,
+...) combined with `&`, `|` and `~`; the first installed handler whose
+matcher matches a query handles it.  Please see
 `isctest/asyncserver/handlers.py` for up-to-date information on available handler classes
 and existing `ans.py` files for how they can be used in practice.  Consult the
 log files (`ans.run`) in case a query is not matched by its intended handler.
@@ -472,15 +466,15 @@ from collections.abc import AsyncGenerator

 import dns.flags

-from isctest.asyncserver import AsyncDnsServer, QueryContext
+from isctest.asyncserver import AsyncDnsServer, QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import DomainHandler
+from isctest.asyncserver.matchers import Domain


-class TruncateHandler(DomainHandler):
+class TruncateHandler(ResponseHandler):
     """Answer everything under broken.example. with TC=1."""

-    domains = ["broken.example."]
+    matcher = Domain("broken.example.")

     async def get_responses(
         self, qctx: QueryContext
@@ -584,8 +578,8 @@ delegation pattern end to end.
 The existing mock servers are the best reference.  To find them, grep for
 what you're about to use:
 `git grep -l isctest.asyncserver -- '*/ans*/ans.py'` lists every python
-mock, and a grep for the base class
-(`DomainHandler`, `QnameHandler`, `ConnectionHandler`) or the response
+mock, and a grep for the matcher (`Domain`, `Qname`, ...), the base class
+(`StaticResponseHandler`, `ConnectionHandler`) or the response
 action (`ResponseDrop`, `BytesResponseSend`, ...) you need usually turns
 up a test already doing something similar.  The full toolbox lives in
 `isctest/asyncserver/` (query matching, TCP connection handling, TSIG
diff --git a/bin/tests/system/chain/ans3/ans.py b/bin/tests/system/chain/ans3/ans.py
index 4e06df3551..ba9645234d 100644
--- a/bin/tests/system/chain/ans3/ans.py
+++ b/bin/tests/system/chain/ans3/ans.py
@@ -18,9 +18,9 @@ import dns.rdatatype
 import dns.rrset
 import dns.zone

-from isctest.asyncserver import AsyncDnsServer, QueryContext
+from isctest.asyncserver import AsyncDnsServer, QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import DomainHandler
+from isctest.asyncserver.matchers import Domain


 def get_dname_rrset_at_name(
@@ -35,13 +35,13 @@ def get_dname_rrset_at_name(
     return rrset


-class CnameThenDnameHandler(DomainHandler):
+class CnameThenDnameHandler(ResponseHandler):
     """
     For certain trigger QNAMEs, insert a DNAME RRset after the CNAME chain
     prepared from zone data.
     """

-    domains = ["example.broken."]
+    matcher = Domain("example.broken.")

     async def get_responses(
         self, qctx: QueryContext
@@ -60,7 +60,7 @@ class CnameThenDnameHandler(DomainHandler):
         yield DnsResponseSend(qctx.response)


-class Cve202125215(DomainHandler):
+class Cve202125215(ResponseHandler):
     """
     Attempt to trigger the resolver variant of CVE-2021-25215.  A `named`
     instance cannot be used for serving the DNAME records returned by this
@@ -68,7 +68,7 @@ class Cve202125215(DomainHandler):
     crash while answering the queries sent by the tested resolver.
     """

-    domains = ["example.dname."]
+    matcher = Domain("example.dname.")

     async def get_responses(
         self, qctx: QueryContext
diff --git a/bin/tests/system/chain/ans4/ans.py b/bin/tests/system/chain/ans4/ans.py
index b9771b03f8..8e0003cd1a 100644
--- a/bin/tests/system/chain/ans4/ans.py
+++ b/bin/tests/system/chain/ans4/ans.py
@@ -23,9 +23,14 @@ import dns.rdataclass
 import dns.rdatatype
 import dns.rrset

-from isctest.asyncserver import ControlCommand, ControllableAsyncDnsServer, QueryContext
+from isctest.asyncserver import (
+    ControlCommand,
+    ControllableAsyncDnsServer,
+    QueryContext,
+    ResponseHandler,
+)
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import DomainHandler
+from isctest.asyncserver.matchers import Domain


 class ChainNameGenerator:
@@ -401,7 +406,7 @@ class ChainSetupCommand(ControlCommand):
         return rrsets


-class ChainResponseHandler(DomainHandler):
+class ChainResponseHandler(ResponseHandler):
     """
     For trigger queries (`test.domain.nil`), return a chained response
     previously prepared by `ChainSetupCommand`.
@@ -409,7 +414,7 @@ class ChainResponseHandler(DomainHandler):
     For any other query, return a non-chained response (a single A RRset).
     """

-    domains = ["domain.nil."]
+    matcher = Domain("domain.nil.")

     def __init__(self, answer_rrsets: list[dns.rrset.RRset]):
         super().__init__()
diff --git a/bin/tests/system/digdelv/ans7/ans.py b/bin/tests/system/digdelv/ans7/ans.py
index 63c8f8ac4d..98f0262fde 100644
--- a/bin/tests/system/digdelv/ans7/ans.py
+++ b/bin/tests/system/digdelv/ans7/ans.py
@@ -13,25 +13,26 @@ from collections.abc import AsyncGenerator

 import dns.rcode

-from isctest.asyncserver import AsyncDnsServer, QueryContext
+from isctest.asyncserver import AsyncDnsServer, QueryContext, ResponseHandler
 from isctest.asyncserver.actions import CloseConnection, DnsResponseSend, ResponseDrop
-from isctest.asyncserver.handlers import DomainHandler, IgnoreAllQueries
+from isctest.asyncserver.handlers import IgnoreAllQueries
+from isctest.asyncserver.matchers import Domain


-class SilentHandler(DomainHandler, IgnoreAllQueries):
+class SilentHandler(IgnoreAllQueries):
     """
     Handler that doesn't respond.
     """

-    domains = ["silent.example"]
+    matcher = Domain("silent.example")


-class CloseHandler(DomainHandler):
+class CloseHandler(ResponseHandler):
     """
     Handler that doesn't respond and closes TCP connection.
     """

-    domains = ["close.example"]
+    matcher = Domain("close.example")

     async def get_responses(
         self, qctx: QueryContext
@@ -39,12 +40,12 @@ class CloseHandler(DomainHandler):
         yield CloseConnection()


-class SilentThenServfailHandler(DomainHandler):
+class SilentThenServfailHandler(ResponseHandler):
     """
     Handler that drops one query and response to the next one with SERVFAIL.
     """

-    domains = ["silent-then-servfail.example"]
+    matcher = Domain("silent-then-servfail.example")
     counter = 0

     async def get_responses(
diff --git a/bin/tests/system/dnssec_cname_response/ans2/ans.py b/bin/tests/system/dnssec_cname_response/ans2/ans.py
index 11bbc207e5..bbfe05b133 100644
--- a/bin/tests/system/dnssec_cname_response/ans2/ans.py
+++ b/bin/tests/system/dnssec_cname_response/ans2/ans.py
@@ -21,7 +21,7 @@ import dns.zone

 from isctest.asyncserver import AsyncDnsServer, QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import DomainHandler
+from isctest.asyncserver.matchers import Domain, Qtype

 # 'example.' answers DNSKEY/NSEC/NSEC3/RRSIG queries with a CNAME (the
 # meta-types whose CNAME answer the resolver and validator must cope with).
@@ -62,7 +62,7 @@ def rrset_with_rrsig(
     return rrsets


-class LoneAHandler(DomainHandler):
+class LoneAHandler(ResponseHandler):
     """
     Answer any query with a single unrelated A record (no RRSIG and no
     alias).  An RRSIG query is handled by the resolver as a subset of ANY,
@@ -70,7 +70,7 @@ class LoneAHandler(DomainHandler):
     for a validator that was never started.
     """

-    domains = ["lone-a.example."]
+    matcher = Domain("lone-a.example.")

     async def get_responses(
         self, qctx: QueryContext
@@ -94,40 +94,28 @@ class CnameHandler(ResponseHandler):
         yield DnsResponseSend(qctx.response)


-class ExampleMetatypeCnameHandler(DomainHandler, CnameHandler):
-    domains = ["example."]
-    _qtypes = frozenset(
-        {
-            dns.rdatatype.DNSKEY,
-            dns.rdatatype.NSEC,
-            dns.rdatatype.NSEC3,
-            dns.rdatatype.RRSIG,
-        }
+class ExampleMetatypeCnameHandler(CnameHandler):
+    matcher = Domain("example.") & Qtype(
+        dns.rdatatype.DNSKEY,
+        dns.rdatatype.NSEC,
+        dns.rdatatype.NSEC3,
+        dns.rdatatype.RRSIG,
     )

-    def match(self, qctx: QueryContext) -> bool:
-        return qctx.qtype in self._qtypes and super().match(qctx)
-
-
-class SecureDsCnameHandler(DomainHandler, CnameHandler):
-    domains = ["secure."]

-    def match(self, qctx: QueryContext) -> bool:
-        return qctx.qtype == dns.rdatatype.DS and super().match(qctx)
+class SecureDsCnameHandler(CnameHandler):
+    matcher = Domain("secure.") & Qtype(dns.rdatatype.DS)


-class SignedZoneHandler(DomainHandler):
+class SignedZoneHandler(ResponseHandler):
     """
     Serve a signed zone faithfully.
     """

     def __init__(self, zone: dns.zone.Zone) -> None:
         self._zone = zone
-        super().__init__()
-
-    @property
-    def domains(self) -> list[str]:
-        return [self._zone.origin.to_text()]
+        assert self._zone.origin
+        self.matcher = Domain(self._zone.origin)

     def _soa(self) -> list[dns.rrset.RRset]:
         return rrset_with_rrsig(self._zone, self._zone.origin, dns.rdatatype.SOA)
@@ -156,20 +144,17 @@ class SignedZoneHandler(DomainHandler):
         yield DnsResponseSend(qctx.response)


-class StuffedNxdomainHandler(DomainHandler):
+class StuffedNxdomainHandler(ResponseHandler):
     """
     Answer NXDOMAIN with every NSEC3 RRset from a signed zone.
     """

     def __init__(self, zone: dns.zone.Zone) -> None:
         self._zone = zone
-        super().__init__()
+        assert self._zone.origin
+        self.matcher = Domain(self._zone.origin)
         self._nsec3_authority = self._build_authority()

-    @property
-    def domains(self) -> list[str]:
-        return [self._zone.origin.to_text()]
-
     def _build_authority(self) -> list[dns.rrset.RRset]:
         authority = rrset_with_rrsig(self._zone, self._zone.origin, dns.rdatatype.SOA)
         for name, _ in self._zone.iterate_rdatasets(dns.rdatatype.NSEC3):
diff --git a/bin/tests/system/dnssec_nsec3/ans1/f025.py b/bin/tests/system/dnssec_nsec3/ans1/f025.py
index 7149172fde..953a5ad221 100644
--- a/bin/tests/system/dnssec_nsec3/ans1/f025.py
+++ b/bin/tests/system/dnssec_nsec3/ans1/f025.py
@@ -31,9 +31,9 @@ from dnssec_nsec3.ans1.common import (
     rrset_from_rdata,
     soa_rrset,
 )
-from isctest.asyncserver import QueryContext
+from isctest.asyncserver import QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import DomainHandler
+from isctest.asyncserver.matchers import Domain

 TTL = 300
 PARENT = "f025.test."
@@ -116,8 +116,8 @@ def add_wildcard_answer(response: dns.message.Message, owner: str, child: Key) -
     response.answer.append(wildcard_rrsig(owner, child))


-class F025Handler(DomainHandler):
-    domains = [PARENT, CHILD]
+class F025Handler(ResponseHandler):
+    matcher = Domain(PARENT, CHILD)

     def __init__(self, keys: dict[str, Key]) -> None:
         super().__init__()
diff --git a/bin/tests/system/dnssec_nsec3/ans1/f055.py b/bin/tests/system/dnssec_nsec3/ans1/f055.py
index 2a79d84ea3..3aff055ace 100644
--- a/bin/tests/system/dnssec_nsec3/ans1/f055.py
+++ b/bin/tests/system/dnssec_nsec3/ans1/f055.py
@@ -34,9 +34,9 @@ from dnssec_nsec3.ans1.common import (
     rrset_from_rdata,
     soa_rrset,
 )
-from isctest.asyncserver import QueryContext
+from isctest.asyncserver import QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import DomainHandler
+from isctest.asyncserver.matchers import Domain

 TTL = 300
 F055_ZONE = "f055.test."
@@ -145,8 +145,8 @@ def add_nsec3_nxdomain(
     add_signed(response.authority, chain.cover_for(f"*.{closest}"), signer)


-class F055Handler(DomainHandler):
-    domains = [F055_ZONE]
+class F055Handler(ResponseHandler):
+    matcher = Domain(F055_ZONE)

     def __init__(self, keys: dict[str, Key]) -> None:
         super().__init__()
diff --git a/bin/tests/system/dnssec_parent_rrsig/ans1/ans.py b/bin/tests/system/dnssec_parent_rrsig/ans1/ans.py
index 2912e436e7..750d4cae1e 100644
--- a/bin/tests/system/dnssec_parent_rrsig/ans1/ans.py
+++ b/bin/tests/system/dnssec_parent_rrsig/ans1/ans.py
@@ -29,6 +29,7 @@ import dns.rrset

 from isctest.asyncserver import AsyncDnsServer, QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
+from isctest.asyncserver.matchers import Domain

 TTL = 300
 PARENT = "f044.test."
@@ -124,13 +125,11 @@ class AncestorAdditionalHandler(ResponseHandler):
     def __init__(self, keys: dict[str, Key]) -> None:
         self.keys = keys
         self.parent = name(PARENT)
+        self.matcher = Domain(self.parent)
         self.child = name(CHILD)
         self.query = name(QUERY)
         self.service = name(SERVICE)

-    def match(self, qctx: QueryContext) -> bool:
-        return qctx.qname.is_subdomain(self.parent)
-
     async def get_responses(
         self, qctx: QueryContext
     ) -> AsyncGenerator[DnsResponseSend, None]:
diff --git a/bin/tests/system/dnssec_py/ans4/delegationtrap_ans.py b/bin/tests/system/dnssec_py/ans4/delegationtrap_ans.py
index 95cd72ed0a..d3ce9a48ae 100644
--- a/bin/tests/system/dnssec_py/ans4/delegationtrap_ans.py
+++ b/bin/tests/system/dnssec_py/ans4/delegationtrap_ans.py
@@ -54,9 +54,9 @@ import dns.rdataclass
 import dns.rdatatype
 import dns.rrset

-from isctest.asyncserver import QueryContext
+from isctest.asyncserver import QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import DomainHandler
+from isctest.asyncserver.matchers import Domain

 ZONE_NAME = "attack.delegationtrap."
 SERVER_IP = "10.53.0.4"
@@ -64,12 +64,12 @@ TTL = 300
 PEM_PATH = Path("attack_delegationtrap.pem")


-class DelegationTrapHandler(DomainHandler):
+class DelegationTrapHandler(ResponseHandler):
     """
     Serve every label under attack.delegationtrap. as a secure zone cut.
     """

-    domains = [ZONE_NAME]
+    matcher = Domain(ZONE_NAME)

     def __init__(self) -> None:
         super().__init__()
diff --git a/bin/tests/system/dnssec_py/ans4/noqname_mismatch.py b/bin/tests/system/dnssec_py/ans4/noqname_mismatch.py
index e5c53f0277..0c6c3ce9b1 100644
--- a/bin/tests/system/dnssec_py/ans4/noqname_mismatch.py
+++ b/bin/tests/system/dnssec_py/ans4/noqname_mismatch.py
@@ -36,9 +36,9 @@ import dns.rdataclass
 import dns.rdatatype
 import dns.rrset

-from isctest.asyncserver import QueryContext
+from isctest.asyncserver import QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import DomainHandler
+from isctest.asyncserver.matchers import Domain

 TTL = 300
 ZONE = "f217.test."
@@ -236,12 +236,12 @@ def add_both_attack_answer(response: dns.message.Message) -> None:
     response.authority.append(nsec3_rrsig(NSEC3_OWNER))


-class RuntimeCheckHandler(DomainHandler):
+class RuntimeCheckHandler(ResponseHandler):
     """
     Serve f217.test. and the forged wildcard answers below evil.f217.test.
     """

-    domains = [ZONE]
+    matcher = Domain(ZONE)

     def __init__(self) -> None:
         super().__init__()
diff --git a/bin/tests/system/dnssec_py/ans4/rrsig_labels_signer_ans.py b/bin/tests/system/dnssec_py/ans4/rrsig_labels_signer_ans.py
index ae04cd8833..f4bcdac833 100644
--- a/bin/tests/system/dnssec_py/ans4/rrsig_labels_signer_ans.py
+++ b/bin/tests/system/dnssec_py/ans4/rrsig_labels_signer_ans.py
@@ -53,9 +53,9 @@ import dns.rdatatype
 import dns.rdtypes.ANY.NSEC3
 import dns.rrset

-from isctest.asyncserver import QueryContext
+from isctest.asyncserver import QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import DomainHandler
+from isctest.asyncserver.matchers import Domain

 ZONE_NAME = "attacker.rrsig-labels-signer."
 PARENT_ZONE_NAME = "rrsig-labels-signer."
@@ -109,12 +109,12 @@ def _type_bitmap(*types: int) -> bytes:
     return bytes(bm)


-class AttackerZoneHandler(DomainHandler):
+class AttackerZoneHandler(ResponseHandler):
     """
     Serve attacker.rrsig-labels-signer. with crafted wildcard RRSIG.
     """

-    domains = [ZONE_NAME]
+    matcher = Domain(ZONE_NAME)

     def __init__(self) -> None:
         super().__init__()
diff --git a/bin/tests/system/dnssec_py/ans4/sibling_ds_ans.py b/bin/tests/system/dnssec_py/ans4/sibling_ds_ans.py
index b1e1348ee0..d6ca574580 100644
--- a/bin/tests/system/dnssec_py/ans4/sibling_ds_ans.py
+++ b/bin/tests/system/dnssec_py/ans4/sibling_ds_ans.py
@@ -23,17 +23,17 @@ from collections.abc import AsyncGenerator
 import dns.rdatatype
 import dns.rrset

-from isctest.asyncserver import QueryContext
+from isctest.asyncserver import QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import DomainHandler
+from isctest.asyncserver.matchers import Domain


-class SiblingDsInjectionHandler(DomainHandler):
+class SiblingDsInjectionHandler(ResponseHandler):
     """
     Inject a DS record for sibling.sibling-ds into child.sibling-ds referrals.
     """

-    domains = ["child.sibling-ds."]
+    matcher = Domain("child.sibling-ds.")

     async def get_responses(
         self, qctx: QueryContext
diff --git a/bin/tests/system/forward/ans11/ans.py b/bin/tests/system/forward/ans11/ans.py
index dec74dac7e..5c1a8325ee 100644
--- a/bin/tests/system/forward/ans11/ans.py
+++ b/bin/tests/system/forward/ans11/ans.py
@@ -14,18 +14,22 @@ from collections.abc import AsyncGenerator
 import dns.rdatatype
 import dns.rrset

-from isctest.asyncserver import ControllableAsyncDnsServer, QueryContext
+from isctest.asyncserver import (
+    ControllableAsyncDnsServer,
+    QueryContext,
+    ResponseHandler,
+)
 from isctest.asyncserver.actions import DnsResponseSend
 from isctest.asyncserver.commands import ToggleResponsesCommand
-from isctest.asyncserver.handlers import DomainHandler
+from isctest.asyncserver.matchers import Domain


-class ExtraAnswersHandler(DomainHandler):
+class ExtraAnswersHandler(ResponseHandler):
     """
     Answer from zone data, inserting extra RRsets into responses to A queries.
     """

-    domains = ["attackSecureDomain.net3."]
+    matcher = Domain("attackSecureDomain.net3.")

     async def get_responses(
         self, qctx: QueryContext
diff --git a/bin/tests/system/forward/ans6/ans.py b/bin/tests/system/forward/ans6/ans.py
index 89c08d0018..bb42e9b4f2 100644
--- a/bin/tests/system/forward/ans6/ans.py
+++ b/bin/tests/system/forward/ans6/ans.py
@@ -24,8 +24,8 @@ from isctest.asyncserver import (
 )
 from isctest.asyncserver.actions import DnsResponseSend
 from isctest.asyncserver.commands import ToggleResponsesCommand
-from isctest.asyncserver.handlers import DomainHandler, StaticResponseHandler
-from isctest.asyncserver.matchers import Qname, Qtype
+from isctest.asyncserver.handlers import StaticResponseHandler
+from isctest.asyncserver.matchers import Domain, Qname, Qtype

 SLD = "sld.tld."
 NS1 = f"ns1.{SLD}"
@@ -65,7 +65,7 @@ class Ns1AaaaHandler(StaticResponseHandler):
     edns = None


-class SldDelegationHandler(DomainHandler):
+class SldDelegationHandler(ResponseHandler):
     """
     Delegate every NS query at or below sld.tld. to ns1.sld.tld., copying the
     owner name from the QNAME.  Together with Ns1AHandler / Ns1AaaaHandler
@@ -73,10 +73,7 @@ class SldDelegationHandler(DomainHandler):
     negative catch-all), this drives named's DS-chasing logic.
     """

-    domains = [SLD]
-
-    def match(self, qctx: QueryContext) -> bool:
-        return qctx.qtype == dns.rdatatype.NS and super().match(qctx)
+    matcher = Domain(SLD) & Qtype(dns.rdatatype.NS)

     async def get_responses(
         self, qctx: QueryContext
diff --git a/bin/tests/system/fwdfirst/ans2/ans.py b/bin/tests/system/fwdfirst/ans2/ans.py
index 497cac9618..99324b758e 100644
--- a/bin/tests/system/fwdfirst/ans2/ans.py
+++ b/bin/tests/system/fwdfirst/ans2/ans.py
@@ -15,11 +15,12 @@ import dns.rdatatype
 import dns.rrset

 from isctest.asyncserver import AsyncDnsServer
-from isctest.asyncserver.handlers import DomainHandler, StaticResponseHandler
+from isctest.asyncserver.handlers import StaticResponseHandler
+from isctest.asyncserver.matchers import Domain


-class PoisonReferralForwarder(DomainHandler, StaticResponseHandler):
-    domains = ["fwd.hack."]
+class PoisonReferralForwarder(StaticResponseHandler):
+    matcher = Domain("fwd.hack.")
     authority = [
         dns.rrset.from_text(
             "hack.", 300, dns.rdataclass.IN, dns.rdatatype.NS, "ns.fwd.hack."
diff --git a/bin/tests/system/isctest/asyncserver/handlers.py b/bin/tests/system/isctest/asyncserver/handlers.py
index 9f6f18a0d3..87dfd88c0f 100644
--- a/bin/tests/system/isctest/asyncserver/handlers.py
+++ b/bin/tests/system/isctest/asyncserver/handlers.py
@@ -19,7 +19,6 @@ import logging

 import dns.exception
 import dns.message
-import dns.name
 import dns.rcode
 import dns.rdatatype
 import dns.rrset
@@ -271,52 +270,6 @@ class StaticResponseHandler(ResponseHandler):
         )


-class DomainHandler(ResponseHandler):
-    """
-    Base class used for deriving custom domain handlers.
-
-    The derived class must specify a list of `domains` that it wants to handle.
-    Queries for any of these domains (and their subdomains) will then be passed
-    to the `get_response()` method in the derived class.
-
-    The most specific matching domain is stored in the `matched_domain` attribute.
-    """
-
-    @property
-    @abc.abstractmethod
-    def domains(self) -> list[str]:
-        """
-        A list of domain names handled by this class.
-        """
-        raise NotImplementedError
-
-    def __init__(self) -> None:
-        self._domains: list[dns.name.Name] = sorted(
-            [dns.name.from_text(d) for d in self.domains], reverse=True
-        )
-        self._matched_domain: dns.name.Name | None = None
-
-    @property
-    def matched_domain(self) -> dns.name.Name:
-        assert self._matched_domain is not None
-        return self._matched_domain
-
-    def __str__(self) -> str:
-        return f"{self.__class__.__name__}(domains: {', '.join(self.domains)})"
-
-    def match(self, qctx: QueryContext) -> bool:
-        """
-        Handle queries whose QNAME matches any of the domains handled by this
-        class.
-        """
-        self._matched_domain = None
-        for domain in self._domains:
-            if qctx.qname.is_subdomain(domain):
-                self._matched_domain = domain
-                return True
-        return False
-
-
 class ForwarderHandler(ResponseHandler):
     """
     A handler forwarding all received queries to another DNS server with an
diff --git a/bin/tests/system/isctest/asyncserver/matchers.py b/bin/tests/system/isctest/asyncserver/matchers.py
index 6e9c597a69..bc56dc6255 100644
--- a/bin/tests/system/isctest/asyncserver/matchers.py
+++ b/bin/tests/system/isctest/asyncserver/matchers.py
@@ -170,3 +170,37 @@ class Qtype(Matcher):

     def __str__(self) -> str:
         return f"QTYPE in [{', '.join(map(dns.rdatatype.to_text, self.qtypes))}]"
+
+
+class Domain(Matcher):
+    """
+    Match queries for one of the given domains or any of their subdomains.
+
+    The most specific domain matched is kept in `matched_domain` for the
+    handler to read.
+    """
+
+    def __init__(self, *domains: str | dns.name.Name) -> None:
+        self._domains = sorted(
+            (
+                name if isinstance(name, dns.name.Name) else dns.name.from_text(name)
+                for name in domains
+            ),
+            reverse=True,
+        )
+        self._matched_domain: dns.name.Name | None = None
+
+    @property
+    def matched_domain(self) -> dns.name.Name:
+        assert self._matched_domain is not None, f"{self} has not matched yet"
+        return self._matched_domain
+
+    def match(self, qctx: QueryContext) -> bool:
+        for domain in self._domains:
+            if qctx.qname.is_subdomain(domain):
+                self._matched_domain = domain
+                return True
+        return False
+
+    def __str__(self) -> str:
+        return f"QNAME under [{', '.join(str(name) for name in self._domains)}]"
diff --git a/bin/tests/system/nsec3_impersonation/ans1/ans.py b/bin/tests/system/nsec3_impersonation/ans1/ans.py
index d0b42bd6bb..80e721bed9 100644
--- a/bin/tests/system/nsec3_impersonation/ans1/ans.py
+++ b/bin/tests/system/nsec3_impersonation/ans1/ans.py
@@ -29,7 +29,7 @@ import dns.rrset

 from isctest.asyncserver import AsyncDnsServer, QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.matchers import Qname, Qtype
+from isctest.asyncserver.matchers import Domain, Qname, Qtype

 TTL = 300
 TLD = "tld.test."
@@ -208,8 +208,7 @@ class AttackerZoneHandler(SignedResponseHandler):
     the apex are answered with an NXDOMAIN with no NSEC or NSEC3 present.
     """

-    def match(self, qctx: QueryContext) -> bool:
-        return qctx.qname.is_subdomain(name(ATTACKER))
+    matcher = Domain(ATTACKER)

     def respond(self, qctx: QueryContext) -> None:
         if qctx.qname == name(ATTACKER):
@@ -239,8 +238,7 @@ class TldZoneHandler(SignedResponseHandler):
     The attack assumes that the adversary can inject these responses on-path.
     """

-    def match(self, qctx: QueryContext) -> bool:
-        return qctx.qname.is_subdomain(name(TLD))
+    matcher = Domain(TLD)

     def respond(self, qctx: QueryContext) -> None:
         if qctx.qname == name(TLD):
diff --git a/bin/tests/system/nsec_grandparent/ans1/ans.py b/bin/tests/system/nsec_grandparent/ans1/ans.py
index 712b8577d0..fe9968aa4d 100644
--- a/bin/tests/system/nsec_grandparent/ans1/ans.py
+++ b/bin/tests/system/nsec_grandparent/ans1/ans.py
@@ -29,6 +29,7 @@ import dns.rrset

 from isctest.asyncserver import AsyncDnsServer, QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
+from isctest.asyncserver.matchers import Domain

 TTL = 300
 PARENT = "p031.test."
@@ -305,6 +306,7 @@ class GrandparentNsecHandler(ResponseHandler):
         self.child_key = keys[CHILD]
         self.sibling_key = keys[SIBLING]
         self.parent = name(PARENT)
+        self.matcher = Domain(self.parent)
         self.child = name(CHILD)
         self.sibling = name(SIBLING)
         self.grandchild = name(GRANDCHILD)
@@ -324,9 +326,6 @@ class GrandparentNsecHandler(ResponseHandler):
             self.grandchild_almost_too_many,
         )

-    def match(self, qctx: QueryContext) -> bool:
-        return qctx.qname.is_subdomain(self.parent)
-
     async def get_responses(
         self, qctx: QueryContext
     ) -> AsyncGenerator[DnsResponseSend, None]:
diff --git a/bin/tests/system/nsec_piggyback/ans1/ans.py b/bin/tests/system/nsec_piggyback/ans1/ans.py
index f13518c017..1e3437eef6 100644
--- a/bin/tests/system/nsec_piggyback/ans1/ans.py
+++ b/bin/tests/system/nsec_piggyback/ans1/ans.py
@@ -30,6 +30,7 @@ import dns.rrset

 from isctest.asyncserver import AsyncDnsServer, QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
+from isctest.asyncserver.matchers import Domain

 TTL = 300
 PARENT = "p22.hack."
@@ -173,14 +174,12 @@ class ParentHandler(ResponseHandler):
     def __init__(self, keys: dict[str, Key]) -> None:
         self.keys = keys
         self.parent = name(PARENT)
+        self.matcher = Domain(self.parent)
         self.child = name(CHILD)
         self.victim = name(VICTIM)
         self.aac = name(AAC)
         self.prime_nx = name(PRIME_NX)

-    def match(self, qctx: QueryContext) -> bool:
-        return qctx.qname.is_subdomain(self.parent)
-
     async def get_responses(
         self, qctx: QueryContext
     ) -> AsyncGenerator[DnsResponseSend, None]:
diff --git a/bin/tests/system/nsec_synthesis/ans1/f004.py b/bin/tests/system/nsec_synthesis/ans1/f004.py
index 497c745721..648730d15d 100644
--- a/bin/tests/system/nsec_synthesis/ans1/f004.py
+++ b/bin/tests/system/nsec_synthesis/ans1/f004.py
@@ -21,9 +21,9 @@ import dns.rdataclass
 import dns.rdatatype
 import dns.rrset

-from isctest.asyncserver import QueryContext
+from isctest.asyncserver import QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import DomainHandler
+from isctest.asyncserver.matchers import Domain
 from nsec_synthesis.ans1.common import (
     Key,
     add_signed,
@@ -77,8 +77,8 @@ def garbage_rrsig(covered: dns.rrset.RRset, signer: Key) -> dns.rrset.RRset:
     return dns.rrset.from_rdata(covered.name, covered.ttl, rdata)


-class F004Handler(DomainHandler):
-    domains = [F004_ZONE]
+class F004Handler(ResponseHandler):
+    matcher = Domain(F004_ZONE)

     def __init__(self, keys: dict[str, Key]) -> None:
         super().__init__()
diff --git a/bin/tests/system/nsec_synthesis/ans1/f023.py b/bin/tests/system/nsec_synthesis/ans1/f023.py
index af66ac875c..fe833034af 100644
--- a/bin/tests/system/nsec_synthesis/ans1/f023.py
+++ b/bin/tests/system/nsec_synthesis/ans1/f023.py
@@ -21,9 +21,9 @@ import dns.rdataclass
 import dns.rdatatype
 import dns.rrset

-from isctest.asyncserver import QueryContext
+from isctest.asyncserver import QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import DomainHandler
+from isctest.asyncserver.matchers import Domain
 from nsec_synthesis.ans1.common import (
     Key,
     add_signed,
@@ -81,8 +81,8 @@ def add_parent_denial(
     add_signed(response.authority, nsec, signer)


-class F023Handler(DomainHandler):
-    domains = [F023_ZONE, EVIL]
+class F023Handler(ResponseHandler):
+    matcher = Domain(F023_ZONE, EVIL)

     def __init__(self, keys: dict[str, Key]) -> None:
         super().__init__()
diff --git a/bin/tests/system/nsec_wildcard_wrong_zone/ans1/ans.py b/bin/tests/system/nsec_wildcard_wrong_zone/ans1/ans.py
index b7dc752c2d..60aaeff5c0 100644
--- a/bin/tests/system/nsec_wildcard_wrong_zone/ans1/ans.py
+++ b/bin/tests/system/nsec_wildcard_wrong_zone/ans1/ans.py
@@ -26,9 +26,9 @@ import dns.rdataclass
 import dns.rdatatype
 import dns.rrset

-from isctest.asyncserver import AsyncDnsServer, QueryContext
+from isctest.asyncserver import AsyncDnsServer, QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import DomainHandler
+from isctest.asyncserver.matchers import Domain

 TTL = 300
 VICTIM = "victim.example."
@@ -125,8 +125,8 @@ def add_wildcard_answer(response, key: Key) -> None:
     response.answer.append(dns.rrset.from_rdata(name(CONTROL), TTL, rrsig))


-class NsecWildcardWrongZoneHandler(DomainHandler):
-    domains = [VICTIM, ATTACKER]
+class NsecWildcardWrongZoneHandler(ResponseHandler):
+    matcher = Domain(VICTIM, ATTACKER)

     def __init__(self, keys: dict[str, Key]) -> None:
         super().__init__()
diff --git a/bin/tests/system/qmin/ans2/ans.py b/bin/tests/system/qmin/ans2/ans.py
index b1a4fb90f0..7c0268047c 100644
--- a/bin/tests/system/qmin/ans2/ans.py
+++ b/bin/tests/system/qmin/ans2/ans.py
@@ -16,9 +16,9 @@ import dns.rcode
 import dns.rdatatype
 import dns.rrset

-from isctest.asyncserver import AsyncDnsServer, QueryContext
+from isctest.asyncserver import AsyncDnsServer, QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import DomainHandler
+from isctest.asyncserver.matchers import Domain

 from ..qmin_ans import (
     DelayedResponseHandler,
@@ -29,21 +29,21 @@ from ..qmin_ans import (


 class QueryLogger(QueryLogHandler):
-    domains = ["1.0.0.2.ip6.arpa.", "fwd.", "good."]
+    matcher = Domain("1.0.0.2.ip6.arpa.", "fwd.", "good.")


 class BadHandler(EntRcodeChanger):
-    domains = ["bad."]
+    matcher = Domain("bad.")
     rcode = dns.rcode.NXDOMAIN


 class UglyHandler(EntRcodeChanger):
-    domains = ["ugly."]
+    matcher = Domain("ugly.")
     rcode = dns.rcode.FORMERR


 class SlowHandler(DelayedResponseHandler):
-    domains = ["slow."]
+    matcher = Domain("slow.")
     delay = 0.2


@@ -67,7 +67,7 @@ def send_delegation(
     return DnsResponseSend(response, authoritative=False)


-class StaleHandler(DomainHandler):
+class StaleHandler(ResponseHandler):
     """
     `a.b.stale` is a subdomain of `b.stale` and these two subdomains need to be
     delegated to different name servers.  Therefore, their delegations cannot
@@ -76,7 +76,7 @@ class StaleHandler(DomainHandler):
     on the QNAME.
     """

-    domains = ["stale."]
+    matcher = Domain("stale.")

     async def get_responses(
         self, qctx: QueryContext
diff --git a/bin/tests/system/qmin/ans3/ans.py b/bin/tests/system/qmin/ans3/ans.py
index 4a86fbae72..7700b0d541 100644
--- a/bin/tests/system/qmin/ans3/ans.py
+++ b/bin/tests/system/qmin/ans3/ans.py
@@ -12,26 +12,27 @@
 import dns.rcode

 from isctest.asyncserver import AsyncDnsServer
+from isctest.asyncserver.matchers import Domain

 from ..qmin_ans import DelayedResponseHandler, EntRcodeChanger, QueryLogHandler


 class QueryLogger(QueryLogHandler):
-    domains = ["8.2.6.0.1.0.0.2.ip6.arpa.", "a.b.stale.", "zoop.boing.good."]
+    matcher = Domain("8.2.6.0.1.0.0.2.ip6.arpa.", "a.b.stale.", "zoop.boing.good.")


 class ZoopBoingBadHandler(EntRcodeChanger):
-    domains = ["zoop.boing.bad."]
+    matcher = Domain("zoop.boing.bad.")
     rcode = dns.rcode.NXDOMAIN


 class ZoopBoingUglyHandler(EntRcodeChanger):
-    domains = ["zoop.boing.ugly."]
+    matcher = Domain("zoop.boing.ugly.")
     rcode = dns.rcode.FORMERR


 class ZoopBoingSlowHandler(DelayedResponseHandler):
-    domains = ["zoop.boing.slow."]
+    matcher = Domain("zoop.boing.slow.")
     delay = 0.4


diff --git a/bin/tests/system/qmin/ans4/ans.py b/bin/tests/system/qmin/ans4/ans.py
index 024e22c3fe..245241e96e 100644
--- a/bin/tests/system/qmin/ans4/ans.py
+++ b/bin/tests/system/qmin/ans4/ans.py
@@ -14,9 +14,9 @@ from collections.abc import AsyncGenerator
 import dns.rcode
 import dns.rdatatype

-from isctest.asyncserver import AsyncDnsServer, QueryContext
+from isctest.asyncserver import AsyncDnsServer, QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import DomainHandler
+from isctest.asyncserver.matchers import Domain

 from ..qmin_ans import (
     DelayedResponseHandler,
@@ -27,13 +27,10 @@ from ..qmin_ans import (


 class QueryLogger(QueryLogHandler):
-    domains = [
-        "1.1.1.1.8.2.6.0.1.0.0.2.ip6.arpa.",
-        "icky.ptang.zoop.boing.good.",
-    ]
+    matcher = Domain("1.1.1.1.8.2.6.0.1.0.0.2.ip6.arpa.", "icky.ptang.zoop.boing.good.")


-class StaleHandler(DomainHandler):
+class StaleHandler(ResponseHandler):
     """
     The test code relies on this server returning non-minimal (i.e. including
     address records in the ADDITIONAL section) responses to NS queries for
@@ -44,7 +41,7 @@ class StaleHandler(DomainHandler):
     handler implemented below.
     """

-    domains = ["b.stale", "a.b.stale"]
+    matcher = Domain("b.stale", "a.b.stale")

     async def get_responses(
         self, qctx: QueryContext
@@ -69,17 +66,17 @@ class StaleHandler(DomainHandler):


 class IckyPtangZoopBoingBadHandler(EntRcodeChanger):
-    domains = ["icky.ptang.zoop.boing.bad."]
+    matcher = Domain("icky.ptang.zoop.boing.bad.")
     rcode = dns.rcode.NXDOMAIN


 class IckyPtangZoopBoingUglyHandler(EntRcodeChanger):
-    domains = ["icky.ptang.zoop.boing.ugly."]
+    matcher = Domain("icky.ptang.zoop.boing.ugly.")
     rcode = dns.rcode.FORMERR


 class IckyPtangZoopBoingSlowHandler(DelayedResponseHandler):
-    domains = ["icky.ptang.zoop.boing.slow."]
+    matcher = Domain("icky.ptang.zoop.boing.slow.")
     delay = 0.4


diff --git a/bin/tests/system/qmin/qmin_ans.py b/bin/tests/system/qmin/qmin_ans.py
index 285578e332..44c33a9bd8 100644
--- a/bin/tests/system/qmin/qmin_ans.py
+++ b/bin/tests/system/qmin/qmin_ans.py
@@ -16,9 +16,8 @@ import abc
 import dns.rcode
 import dns.rdatatype

-from isctest.asyncserver import QueryContext
+from isctest.asyncserver import QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import DomainHandler


 def log_query(qctx: QueryContext) -> None:
@@ -36,7 +35,7 @@ def log_query(qctx: QueryContext) -> None:
         print(f"{qtype} {qname}", file=query_log)


-class QueryLogHandler(DomainHandler):
+class QueryLogHandler(ResponseHandler):
     """
     Log all received DNS queries to a text file.  Use the zone file for
     preparing responses.
@@ -49,7 +48,7 @@ class QueryLogHandler(DomainHandler):
         yield DnsResponseSend(qctx.response)


-class EntRcodeChanger(DomainHandler):
+class EntRcodeChanger(ResponseHandler):
     """
     Log all received DNS queries to a text file.  Use the zone file for
     preparing responses, but override the RCODE returned for empty
@@ -80,7 +79,7 @@ class EntRcodeChanger(DomainHandler):
             yield DnsResponseSend(qctx.response)


-class DelayedResponseHandler(DomainHandler):
+class DelayedResponseHandler(ResponseHandler):
     """
     Log all received DNS queries to a text file.  Use the zone file for
     preparing responses, but delay sending every answer by the amount of time
diff --git a/bin/tests/system/resend_loop/ans3/ans.py b/bin/tests/system/resend_loop/ans3/ans.py
index 6dc750c644..20b74b4d53 100644
--- a/bin/tests/system/resend_loop/ans3/ans.py
+++ b/bin/tests/system/resend_loop/ans3/ans.py
@@ -18,10 +18,10 @@ import dns.rdataclass
 import dns.rdatatype
 import dns.rrset

-from isctest.asyncserver import AsyncDnsServer, QueryContext
+from isctest.asyncserver import AsyncDnsServer, QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import DomainHandler, StaticResponseHandler
-from isctest.asyncserver.matchers import Qname, Qtype
+from isctest.asyncserver.handlers import StaticResponseHandler
+from isctest.asyncserver.matchers import Domain, Qname, Qtype


 def rrset(
@@ -45,8 +45,8 @@ class ExampleNsHandler(StaticResponseHandler):
     additional = [rrset("ns.example.", dns.rdatatype.A, "10.53.0.3")]


-class ExampleCookieHandler(DomainHandler):
-    domains = ["example."]
+class ExampleCookieHandler(ResponseHandler):
+    matcher = Domain("example.")

     def _get_cookie(self, qctx: QueryContext) -> dns.edns.CookieOption | None:
         for o in qctx.query.options:
diff --git a/bin/tests/system/resolver/ans2/ans.py b/bin/tests/system/resolver/ans2/ans.py
index 7f8e446b3f..29dd9e8a4a 100644
--- a/bin/tests/system/resolver/ans2/ans.py
+++ b/bin/tests/system/resolver/ans2/ans.py
@@ -19,12 +19,8 @@ import dns.rrset

 from isctest.asyncserver import AsyncDnsServer, QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import (
-    DomainHandler,
-    IgnoreAllQueries,
-    StaticResponseHandler,
-)
-from isctest.asyncserver.matchers import Qname, Qtype
+from isctest.asyncserver.handlers import IgnoreAllQueries, StaticResponseHandler
+from isctest.asyncserver.matchers import Domain, Qname, Qtype

 from ..resolver_ans import (
     DelegationHandler,
@@ -165,8 +161,8 @@ class Ns7Delegation(DelegationHandler):
     server_number = 7


-class PartialFormerrHandler(DomainHandler, StaticResponseHandler):
-    domains = ["partial-formerr."]
+class PartialFormerrHandler(StaticResponseHandler):
+    matcher = Domain("partial-formerr.")
     authoritative = False
     rcode = dns.rcode.FORMERR

diff --git a/bin/tests/system/resolver/ans3/ans.py b/bin/tests/system/resolver/ans3/ans.py
index e53ac71796..38eb5ff14f 100644
--- a/bin/tests/system/resolver/ans3/ans.py
+++ b/bin/tests/system/resolver/ans3/ans.py
@@ -17,12 +17,8 @@ import dns.rdatatype

 from isctest.asyncserver import AsyncDnsServer, QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import (
-    DomainHandler,
-    IgnoreAllQueries,
-    StaticResponseHandler,
-)
-from isctest.asyncserver.matchers import Qname, Qtype
+from isctest.asyncserver.handlers import IgnoreAllQueries, StaticResponseHandler
+from isctest.asyncserver.matchers import Domain, Qname, Qtype

 from ..resolver_ans import (
     DelegationHandler,
@@ -174,8 +170,8 @@ class OkSubHandler(ResponseHandler):
         yield DnsResponseSend(qctx.response)


-class PartialFormerrHandler(DomainHandler):
-    domains = ["partial-formerr."]
+class PartialFormerrHandler(ResponseHandler):
+    matcher = Domain("partial-formerr.")

     async def get_responses(
         self, qctx: QueryContext
diff --git a/bin/tests/system/resolver/ans8/ans.py b/bin/tests/system/resolver/ans8/ans.py
index b5506aedca..00ddfcdf91 100644
--- a/bin/tests/system/resolver/ans8/ans.py
+++ b/bin/tests/system/resolver/ans8/ans.py
@@ -26,8 +26,8 @@ from isctest.asyncserver import (
     ResponseHandler,
 )
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import DomainHandler, StaticResponseHandler
-from isctest.asyncserver.matchers import Qname, Qtype
+from isctest.asyncserver.handlers import StaticResponseHandler
+from isctest.asyncserver.matchers import Domain, Qname, Qtype

 from ..resolver_ans import rrset

@@ -77,8 +77,8 @@ class TcpFallbackHandler(ResponseHandler):
         yield DnsResponseSend(qctx.response)


-class FormerrToAllHandler(DomainHandler, StaticResponseHandler):
-    domains = ["formerr-to-all."]
+class FormerrToAllHandler(StaticResponseHandler):
+    matcher = Domain("formerr-to-all.")
     rcode = dns.rcode.FORMERR


diff --git a/bin/tests/system/resolver/resolver_ans.py b/bin/tests/system/resolver/resolver_ans.py
index cd1d3b1d6a..ca8a925de5 100644
--- a/bin/tests/system/resolver/resolver_ans.py
+++ b/bin/tests/system/resolver/resolver_ans.py
@@ -21,8 +21,7 @@ import dns.rrset

 from isctest.asyncserver import QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.handlers import DomainHandler
-from isctest.asyncserver.matchers import Qname
+from isctest.asyncserver.matchers import Domain, Qname


 def rrset(
@@ -72,16 +71,25 @@ def setup_delegation(
     qctx.response.additional.append(delegation.a_rrset)


-class DelegationHandler(DomainHandler):
+class DelegationHandler(ResponseHandler):
+    @property
+    @abc.abstractmethod
+    def domains(self) -> list[str]:
+        raise NotImplementedError
+
     @property
     @abc.abstractmethod
     def server_number(self) -> int:
         raise NotImplementedError

+    def __init__(self) -> None:
+        self.matcher = Domain(*self.domains)
+
     async def get_responses(
         self, qctx: QueryContext
     ) -> AsyncGenerator[DnsResponseSend, None]:
-        setup_delegation(qctx, self.matched_domain, self.server_number)
+        domain = self.matcher.of(Domain).matched_domain
+        setup_delegation(qctx, domain, self.server_number)
         yield DnsResponseSend(qctx.response, authoritative=False)