Which was extremely frustrating. After an hour of clearing cache, hitting Shift-F5 and lots of online search, I asked about it in the forum. Got an answer from Pavel-ww very quickly: in the advanced options, you need to choose the Module Style "NoCard" in order for a custom class to work.
That worked, but it didn't seem very logical. So I did some further research.
In Joomla, module styles define the HTML output for modules.
These styles control the output for the module title, heading and class suffixes.
In J!3, module styles were managed through a unique file named modules.php located inside the /html folder of each template.
Starting in J!4, module styles are managed in layout files - one for each module style. The HTML structure for a style is defined in /templates/cassiopeia/html/layouts/chromes/[style].php.
I tested a custom module with each Module Style, plus a custom class "mymoduleclass". It turns out that in fact they all retain a custom class, except "None" (and "Outline", but that makes sense).
What's also nice: each module has a unique ID.
Here's what I found.
System Style: HTML5
Picks up the custom class.
Title and content in separate divs.
The title gets an <h3> tag (you can set that tag in the options).
<div class="moduletable mymoduleclass">
<h3>Testing Module Styles</h3>
<div id="mod-custom119" class="mod-custom custom">
<p>Module content goes here</p>
</div>
</div>
Style: None
No custom class. No title either.
No styling (though Cassiopeia adds a margin-top).
<div id="mod-custom119" class="mod-custom custom">
<p>Module content goes here</p>
</div>
Style: Outline
Adds position and style, used for showing module positions in the front-end (?tp=1).
Style: Table
Makes an actual table... The content goes inside a div, inside a <td>.
Custom class is added.
Title goes into a <th> (without a header tag).
<table class="moduletable mymoduleclass">
<tbody>
<tr>
<th>Testing Module Styles</th>
</tr>
<tr>
<td>
<div id="mod-custom119" class="mod-custom custom">
<p>Module content goes here</p>
</div>
</td>
</tr>
</tbody>
</table>
Style: Cassiopeia Card
Keeps the custom class.
Header and content in separate divs.
Extra div for "card-body".
Adds some classes for additional Cassiopeia styling.
<div class="sidebar-right card mymoduleclass">
<h3 class="card-header ">Testing Module Styles</h3>
<div class="card-body">
<div id="mod-custom119" class="mod-custom custom">
<p>Module content goes here</p>
</div>
</div>
</div>
Style: Cassiopeia Nocard
Keeps custom class.
Header and content in separate divs.
<div class="sidebar-right no-card mymoduleclass">
<h3>Testing Module Styles</h3>
<div id="mod-custom119" class="mod-custom custom">
<p>Module content goes here</p>
</div>
</div>